From b6c7581a31a1087d26d8de3b2405af05bfb96bdd Mon Sep 17 00:00:00 2001 From: Coding On Star <447357187@qq.com> Date: Fri, 17 Apr 2026 13:53:51 +0800 Subject: [PATCH 01/24] refactor(web): replace portal component with DropdownMenu in various components (#35319) Signed-off-by: dependabot[bot] Co-authored-by: CodingOnStar Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: yyh Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: jerryzai Co-authored-by: NVIDIAN Co-authored-by: ai-hpc Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com> Co-authored-by: Asuka Minato Co-authored-by: Junghwan <70629228+shaun0927@users.noreply.github.com> Co-authored-by: HeYinKazune <70251095+HeYin-OS@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- eslint-suppressions.json | 115 --- .../app-sidebar/dataset-info-flow.test.tsx | 2 +- .../app-sidebar/sidebar-shell-flow.test.tsx | 6 +- .../__tests__/app-sidebar-dropdown.spec.tsx | 51 +- .../dataset-sidebar-dropdown.spec.tsx | 49 +- .../__tests__/app-operations.spec.tsx | 74 +- .../app-sidebar/app-info/app-operations.tsx | 80 +- .../app-sidebar/app-sidebar-dropdown.tsx | 68 +- .../__tests__/dropdown-callbacks.spec.tsx | 41 +- .../dataset-info/__tests__/index.spec.tsx | 8 +- .../app-sidebar/dataset-info/dropdown.tsx | 58 +- .../app-sidebar/dataset-sidebar-dropdown.tsx | 72 +- .../publish-with-multiple-model.spec.tsx | 57 +- .../publish-with-multiple-model.tsx | 103 ++- .../__tests__/debug-item.spec.tsx | 123 ++- .../debug-with-multiple-model/debug-item.tsx | 135 ++-- .../components/base/action-button/index.tsx | 4 +- .../__tests__/header-in-mobile.spec.tsx | 22 +- .../mobile-operation-dropdown.spec.tsx | 23 +- .../header/__tests__/operation.spec.tsx | 12 +- .../header/mobile-operation-dropdown.tsx | 60 +- .../chat-with-history/header/operation.tsx | 77 +- .../sidebar/__tests__/operation.spec.tsx | 75 +- .../chat-with-history/sidebar/operation.tsx | 109 +-- .../base/dropdown/__tests__/index.spec.tsx | 225 ------ .../base/dropdown/index.stories.tsx | 88 --- web/app/components/base/dropdown/index.tsx | 122 --- .../breadcrumbs/__tests__/index.spec.tsx | 79 +- .../dropdown/__tests__/index.spec.tsx | 6 +- .../header/breadcrumbs/dropdown/index.tsx | 36 +- .../item-operation/__tests__/index.spec.tsx | 112 ++- .../explore/item-operation/index.tsx | 101 ++- .../__tests__/card.spec.tsx | 28 +- .../__tests__/item.spec.tsx | 5 +- .../__tests__/operator.spec.tsx | 43 +- .../data-source-page-new/operator.tsx | 163 ++-- .../members-page/operation/index.tsx | 81 +- .../sort-dropdown/__tests__/index.spec.tsx | 737 +++--------------- .../marketplace/sort-dropdown/index.tsx | 85 +- .../install-plugin-dropdown.spec.tsx | 107 ++- .../plugin-page/install-plugin-dropdown.tsx | 118 +-- .../plugin-tasks/__tests__/index.spec.tsx | 44 ++ .../components/__tests__/plugin-item.spec.tsx | 1 + .../components/error-plugin-item.tsx | 2 +- .../plugin-tasks/components/plugin-item.tsx | 4 +- .../components/plugin-section.tsx | 2 +- .../components/plugin-task-list.tsx | 2 +- .../plugin-page/plugin-tasks/index.tsx | 38 +- .../__tests__/menu-dropdown.spec.tsx | 40 + .../share/text-generation/menu-dropdown.tsx | 135 ++-- .../__tests__/operation-dropdown.spec.tsx | 192 +++-- .../tools/mcp/detail/operation-dropdown.tsx | 92 +-- .../__tests__/action.spec.tsx | 124 +++ .../market-place-plugin/action.tsx | 65 +- .../__tests__/test-run-menu-helpers.spec.tsx | 29 +- .../header/__tests__/test-run-menu.spec.tsx | 78 +- .../workflow/header/test-run-menu-helpers.tsx | 8 +- .../workflow/header/test-run-menu.tsx | 37 +- .../next-step/__tests__/operator.spec.tsx | 152 ++++ .../_base/components/next-step/operator.tsx | 42 +- .../panel-operator/__tests__/index.spec.tsx | 17 +- .../_base/components/panel-operator/index.tsx | 42 +- .../__tests__/operation-selector.spec.tsx | 69 ++ .../components/operation-selector.tsx | 111 ++- .../note-node/__tests__/index.spec.tsx | 2 + .../components/workflow/note-node/index.tsx | 2 +- .../toolbar/__tests__/index.spec.tsx | 2 +- .../toolbar/__tests__/operator.spec.tsx | 158 +++- .../note-node/note-editor/toolbar/index.tsx | 6 +- .../note-editor/toolbar/operator.tsx | 84 +- .../operator/__tests__/more-actions.spec.tsx | 309 ++++++++ .../operator/__tests__/zoom-in-out.spec.tsx | 197 +++++ .../workflow/operator/more-actions.tsx | 143 ++-- .../workflow/operator/zoom-in-out.tsx | 239 +++--- .../context-menu/__tests__/menu-item.spec.tsx | 21 +- .../context-menu/index.tsx | 95 +-- .../context-menu/menu-item.tsx | 15 +- .../__tests__/agent-log-nav-more.spec.tsx | 46 ++ .../run/agent-log/agent-log-nav-more.tsx | 74 +- .../components/__tests__/zoom-in-out.spec.tsx | 20 +- .../components/zoom-in-out.tsx | 205 +++-- web/docs/overlay-migration.md | 8 - web/eslint.constants.mjs | 8 - 83 files changed, 3453 insertions(+), 3067 deletions(-) delete mode 100644 web/app/components/base/dropdown/__tests__/index.spec.tsx delete mode 100644 web/app/components/base/dropdown/index.stories.tsx delete mode 100644 web/app/components/base/dropdown/index.tsx create mode 100644 web/app/components/workflow/block-selector/market-place-plugin/__tests__/action.spec.tsx create mode 100644 web/app/components/workflow/nodes/_base/components/next-step/__tests__/operator.spec.tsx create mode 100644 web/app/components/workflow/operator/__tests__/more-actions.spec.tsx create mode 100644 web/app/components/workflow/operator/__tests__/zoom-in-out.spec.tsx diff --git a/eslint-suppressions.json b/eslint-suppressions.json index e4831c4e98..763d94af9a 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -218,36 +218,20 @@ } }, "web/app/components/app-sidebar/app-info/app-operations.tsx": { - "no-restricted-imports": { - "count": 1 - }, "react/set-state-in-effect": { "count": 4 } }, - "web/app/components/app-sidebar/app-sidebar-dropdown.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/app-sidebar/basic.tsx": { "no-restricted-imports": { "count": 1 } }, "web/app/components/app-sidebar/dataset-info/dropdown.tsx": { - "no-restricted-imports": { - "count": 1 - }, "ts/no-explicit-any": { "count": 1 } }, - "web/app/components/app-sidebar/dataset-sidebar-dropdown.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/app-sidebar/index.tsx": { "ts/no-explicit-any": { "count": 1 @@ -338,11 +322,6 @@ "count": 5 } }, - "web/app/components/app/app-publisher/publish-with-multiple-model.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/app/app-publisher/version-info-modal.tsx": { "no-restricted-imports": { "count": 1 @@ -575,11 +554,6 @@ "count": 6 } }, - "web/app/components/app/configuration/debug/debug-with-multiple-model/debug-item.tsx": { - "no-restricted-imports": { - "count": 2 - } - }, "web/app/components/app/configuration/debug/debug-with-multiple-model/index.tsx": { "ts/no-explicit-any": { "count": 2 @@ -802,9 +776,6 @@ "web/app/components/base/action-button/index.tsx": { "erasable-syntax-only/enums": { "count": 1 - }, - "react-refresh/only-export-components": { - "count": 1 } }, "web/app/components/base/agent-log-modal/detail.tsx": { @@ -2594,11 +2565,6 @@ "count": 1 } }, - "web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/dropdown/index.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/item.tsx": { "no-restricted-imports": { "count": 1 @@ -3022,9 +2988,6 @@ } }, "web/app/components/explore/item-operation/index.tsx": { - "no-restricted-imports": { - "count": 1 - }, "react/set-state-in-effect": { "count": 1 } @@ -3168,11 +3131,6 @@ "count": 1 } }, - "web/app/components/header/account-setting/data-source-page-new/operator.tsx": { - "no-restricted-imports": { - "count": 2 - } - }, "web/app/components/header/account-setting/data-source-page-new/types.ts": { "ts/no-explicit-any": { "count": 2 @@ -3196,11 +3154,6 @@ "count": 3 } }, - "web/app/components/header/account-setting/members-page/operation/index.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/header/account-setting/members-page/transfer-ownership-modal/index.tsx": { "erasable-syntax-only/enums": { "count": 1 @@ -3490,11 +3443,6 @@ "count": 1 } }, - "web/app/components/plugins/marketplace/sort-dropdown/index.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/plugins/plugin-auth/authorize/add-oauth-button.tsx": { "ts/no-explicit-any": { "count": 2 @@ -3851,9 +3799,6 @@ } }, "web/app/components/plugins/plugin-page/install-plugin-dropdown.tsx": { - "no-restricted-imports": { - "count": 1 - }, "react/set-state-in-effect": { "count": 2 } @@ -3868,11 +3813,6 @@ "count": 1 } }, - "web/app/components/plugins/plugin-page/plugin-tasks/index.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/plugins/readme-panel/index.tsx": { "react/unsupported-syntax": { "count": 1 @@ -4091,9 +4031,6 @@ } }, "web/app/components/share/text-generation/menu-dropdown.tsx": { - "no-restricted-imports": { - "count": 1 - }, "react/set-state-in-effect": { "count": 1 } @@ -4175,11 +4112,6 @@ "count": 3 } }, - "web/app/components/tools/mcp/detail/operation-dropdown.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/tools/mcp/detail/tool-item.tsx": { "no-restricted-imports": { "count": 1 @@ -4349,9 +4281,6 @@ } }, "web/app/components/workflow/block-selector/market-place-plugin/action.tsx": { - "no-restricted-imports": { - "count": 1 - }, "react/set-state-in-effect": { "count": 1 } @@ -4467,9 +4396,6 @@ "erasable-syntax-only/enums": { "count": 1 }, - "no-restricted-imports": { - "count": 1 - }, "react-refresh/only-export-components": { "count": 1 } @@ -4753,11 +4679,6 @@ "count": 1 } }, - "web/app/components/workflow/nodes/_base/components/next-step/operator.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/workflow/nodes/_base/components/node-control.tsx": { "no-restricted-imports": { "count": 1 @@ -4773,11 +4694,6 @@ "count": 1 } }, - "web/app/components/workflow/nodes/_base/components/panel-operator/index.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/workflow/nodes/_base/components/prompt/editor.tsx": { "no-restricted-imports": { "count": 1 @@ -5001,11 +4917,6 @@ "count": 1 } }, - "web/app/components/workflow/nodes/assigner/components/operation-selector.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/workflow/nodes/assigner/default.ts": { "ts/no-explicit-any": { "count": 1 @@ -6010,11 +5921,6 @@ "count": 1 } }, - "web/app/components/workflow/note-node/note-editor/toolbar/operator.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/workflow/note-node/note-editor/utils.ts": { "regexp/no-useless-quantifier": { "count": 1 @@ -6030,11 +5936,6 @@ "count": 1 } }, - "web/app/components/workflow/operator/more-actions.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/workflow/operator/tip-popup.tsx": { "no-restricted-imports": { "count": 1 @@ -6043,9 +5944,6 @@ "web/app/components/workflow/operator/zoom-in-out.tsx": { "erasable-syntax-only/enums": { "count": 1 - }, - "no-restricted-imports": { - "count": 1 } }, "web/app/components/workflow/panel/chat-record/index.tsx": { @@ -6141,11 +6039,6 @@ "count": 4 } }, - "web/app/components/workflow/panel/version-history-panel/context-menu/index.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/workflow/panel/version-history-panel/delete-confirm-modal.tsx": { "no-restricted-imports": { "count": 1 @@ -6166,11 +6059,6 @@ "count": 2 } }, - "web/app/components/workflow/run/agent-log/agent-log-nav-more.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/workflow/run/agent-log/index.tsx": { "no-barrel-files/no-barrel-files": { "count": 2 @@ -6450,9 +6338,6 @@ "web/app/components/workflow/workflow-preview/components/zoom-in-out.tsx": { "erasable-syntax-only/enums": { "count": 1 - }, - "no-restricted-imports": { - "count": 1 } }, "web/app/education-apply/expire-notice-modal.tsx": { diff --git a/web/__tests__/app-sidebar/dataset-info-flow.test.tsx b/web/__tests__/app-sidebar/dataset-info-flow.test.tsx index d1ca233d96..3093b2809d 100644 --- a/web/__tests__/app-sidebar/dataset-info-flow.test.tsx +++ b/web/__tests__/app-sidebar/dataset-info-flow.test.tsx @@ -194,7 +194,7 @@ describe('App Sidebar Dataset Info Flow', () => { openDropdown() fireEvent.click(await screen.findByText('common.operation.edit')) - expect(screen.getByTestId('rename-dataset-modal')).toBeInTheDocument() + expect(await screen.findByTestId('rename-dataset-modal')).toBeInTheDocument() fireEvent.click(screen.getByRole('button', { name: 'rename-success' })) diff --git a/web/__tests__/app-sidebar/sidebar-shell-flow.test.tsx b/web/__tests__/app-sidebar/sidebar-shell-flow.test.tsx index 3e3edba5dd..a7c660105d 100644 --- a/web/__tests__/app-sidebar/sidebar-shell-flow.test.tsx +++ b/web/__tests__/app-sidebar/sidebar-shell-flow.test.tsx @@ -181,7 +181,7 @@ describe('App Sidebar Shell Flow', () => { expect(mockSetAppSidebarExpand).toHaveBeenCalledWith('collapse') }) - it('switches to the workflow fullscreen dropdown shell and opens its navigation menu', () => { + it('switches to the workflow fullscreen dropdown shell and opens its navigation menu', async () => { mockPathname = '/app/app-1/workflow' mockSelectedSegment = 'workflow' localStorage.setItem('workflow-canvas-maximize', 'true') @@ -190,9 +190,9 @@ describe('App Sidebar Shell Flow', () => { expect(screen.queryByTestId('app-info')).not.toBeInTheDocument() - fireEvent.click(screen.getByTestId('portal-trigger')) + fireEvent.click(screen.getByRole('button', { name: 'operation.more' })) - expect(screen.getByText('Demo App')).toBeInTheDocument() + expect(await screen.findByText('Demo App')).toBeInTheDocument() expect(screen.getByRole('link', { name: /Overview/i })).toBeInTheDocument() expect(screen.getByRole('link', { name: /Logs/i })).toBeInTheDocument() }) diff --git a/web/app/components/app-sidebar/__tests__/app-sidebar-dropdown.spec.tsx b/web/app/components/app-sidebar/__tests__/app-sidebar-dropdown.spec.tsx index 5018709da1..5e18bbc343 100644 --- a/web/app/components/app-sidebar/__tests__/app-sidebar-dropdown.spec.tsx +++ b/web/app/components/app-sidebar/__tests__/app-sidebar-dropdown.spec.tsx @@ -19,17 +19,40 @@ vi.mock('@/context/app-context', () => ({ }), })) -vi.mock('@/app/components/base/portal-to-follow-elem', () => ({ - PortalToFollowElem: ({ children, open }: { children: React.ReactNode, open: boolean }) => ( -
{children}
- ), - PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick?: () => void }) => ( -
{children}
- ), - PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) => ( -
{children}
- ), -})) +vi.mock('@/app/components/base/ui/dropdown-menu', () => { + const DropdownMenuContext = React.createContext<{ isOpen: boolean, setOpen: (open: boolean) => void } | null>(null) + + const useDropdownMenuContext = () => { + const context = React.use(DropdownMenuContext) + if (!context) + throw new Error('DropdownMenu components must be wrapped in DropdownMenu') + return context + } + + return { + DropdownMenu: ({ children, open, onOpenChange }: { children: React.ReactNode, open: boolean, onOpenChange?: (open: boolean) => void }) => ( + +
{children}
+
+ ), + DropdownMenuTrigger: ({ children, onClick }: { children: React.ReactNode, onClick?: React.MouseEventHandler }) => { + const { isOpen, setOpen } = useDropdownMenuContext() + return ( + + ) + }, + DropdownMenuContent: ({ children }: { children: React.ReactNode }) =>
{children}
, + } +}) vi.mock('../../base/app-icon', () => ({ default: ({ size, icon }: { size: string, icon: string }) => ( @@ -128,11 +151,11 @@ describe('AppSidebarDropdown', () => { const user = userEvent.setup() render() - const trigger = screen.getByTestId('portal-trigger') + const trigger = screen.getByTestId('dropdown-trigger') await user.click(trigger) - const portal = screen.getByTestId('portal-elem') - expect(portal).toHaveAttribute('data-open', 'true') + const dropdown = screen.getByTestId('dropdown-menu') + expect(dropdown).toHaveAttribute('data-open', 'true') }) it('should render divider between app info and navigation', () => { diff --git a/web/app/components/app-sidebar/__tests__/dataset-sidebar-dropdown.spec.tsx b/web/app/components/app-sidebar/__tests__/dataset-sidebar-dropdown.spec.tsx index 1f3a5f9ad8..5060987cda 100644 --- a/web/app/components/app-sidebar/__tests__/dataset-sidebar-dropdown.spec.tsx +++ b/web/app/components/app-sidebar/__tests__/dataset-sidebar-dropdown.spec.tsx @@ -21,17 +21,40 @@ vi.mock('@/hooks/use-knowledge', () => ({ }), })) -vi.mock('@/app/components/base/portal-to-follow-elem', () => ({ - PortalToFollowElem: ({ children, open }: { children: React.ReactNode, open: boolean }) => ( -
{children}
- ), - PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick?: () => void }) => ( -
{children}
- ), - PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) => ( -
{children}
- ), -})) +vi.mock('@/app/components/base/ui/dropdown-menu', () => { + const DropdownMenuContext = React.createContext<{ isOpen: boolean, setOpen: (open: boolean) => void } | null>(null) + + const useDropdownMenuContext = () => { + const context = React.use(DropdownMenuContext) + if (!context) + throw new Error('DropdownMenu components must be wrapped in DropdownMenu') + return context + } + + return { + DropdownMenu: ({ children, open, onOpenChange }: { children: React.ReactNode, open: boolean, onOpenChange?: (open: boolean) => void }) => ( + +
{children}
+
+ ), + DropdownMenuTrigger: ({ children, onClick }: { children: React.ReactNode, onClick?: React.MouseEventHandler }) => { + const { isOpen, setOpen } = useDropdownMenuContext() + return ( + + ) + }, + DropdownMenuContent: ({ children }: { children: React.ReactNode }) =>
{children}
, + } +}) vi.mock('../../base/app-icon', () => ({ default: ({ size, icon }: { size: string, icon: string }) => ( @@ -173,10 +196,10 @@ describe('DatasetSidebarDropdown', () => { const user = userEvent.setup() render() - const trigger = screen.getByTestId('portal-trigger') + const trigger = screen.getByTestId('dropdown-trigger') await user.click(trigger) - expect(screen.getByTestId('portal-elem')).toHaveAttribute('data-open', 'true') + expect(screen.getByTestId('dropdown-menu')).toHaveAttribute('data-open', 'true') }) it('should render divider', () => { diff --git a/web/app/components/app-sidebar/app-info/__tests__/app-operations.spec.tsx b/web/app/components/app-sidebar/app-info/__tests__/app-operations.spec.tsx index 2c5b133a74..461cedc20c 100644 --- a/web/app/components/app-sidebar/app-info/__tests__/app-operations.spec.tsx +++ b/web/app/components/app-sidebar/app-info/__tests__/app-operations.spec.tsx @@ -30,17 +30,67 @@ vi.mock('../../../base/ui/button', () => ({ ), })) -vi.mock('../../../base/portal-to-follow-elem', () => ({ - PortalToFollowElem: ({ children, open }: { children: React.ReactNode, open: boolean }) => ( -
{children}
- ), - PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick?: () => void }) => ( -
{children}
- ), - PortalToFollowElemContent: ({ children, className }: { children: React.ReactNode, className?: string }) => ( -
{children}
- ), -})) +vi.mock('../../../base/ui/dropdown-menu', () => { + const DropdownMenuContext = React.createContext<{ isOpen: boolean, setOpen: (open: boolean) => void } | null>(null) + + const useDropdownMenuContext = () => { + const context = React.use(DropdownMenuContext) + if (!context) + throw new Error('DropdownMenu components must be wrapped in DropdownMenu') + return context + } + + return { + DropdownMenu: ({ children, open, onOpenChange }: { children: React.ReactNode, open: boolean, onOpenChange?: (open: boolean) => void }) => ( + +
{children}
+
+ ), + DropdownMenuTrigger: ({ + children, + onClick, + render, + }: { + children: React.ReactNode + onClick?: React.MouseEventHandler + render?: React.ReactElement + }) => { + const { isOpen, setOpen } = useDropdownMenuContext() + const handleClick = (e: React.MouseEvent) => { + onClick?.(e) + setOpen(!isOpen) + } + + if (render) + return React.cloneElement(render, { 'data-testid': 'dropdown-trigger', 'onClick': handleClick } as Record, children) + + return + }, + DropdownMenuContent: ({ children, popupClassName }: { children: React.ReactNode, popupClassName?: string }) => { + const { isOpen } = useDropdownMenuContext() + if (!isOpen) + return null + + return
{children}
+ }, + DropdownMenuItem: ({ children, onClick }: { children: React.ReactNode, onClick?: React.MouseEventHandler }) => { + const { setOpen } = useDropdownMenuContext() + return ( + + ) + }, + DropdownMenuSeparator: () =>
, + } +}) const createOperation = (id: string, title: string, type?: 'divider'): Operation => ({ id, @@ -169,7 +219,7 @@ describe('AppOperations', () => { render() - const trigger = screen.queryByTestId('portal-trigger') + const trigger = screen.queryByTestId('dropdown-trigger') if (trigger) await user.click(trigger) diff --git a/web/app/components/app-sidebar/app-info/app-operations.tsx b/web/app/components/app-sidebar/app-info/app-operations.tsx index a3e67c8a59..095fb31206 100644 --- a/web/app/components/app-sidebar/app-info/app-operations.tsx +++ b/web/app/components/app-sidebar/app-info/app-operations.tsx @@ -1,9 +1,15 @@ import type { JSX } from 'react' import { RiMoreLine } from '@remixicon/react' -import { cloneElement, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { cloneElement, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { Button } from '@/app/components/base/ui/button' -import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '../../base/portal-to-follow-elem' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '../../base/ui/dropdown-menu' export type Operation = { id: string @@ -33,9 +39,6 @@ const AppOperations = ({ const [moreOperations, setMoreOperations] = useState([]) const [showMore, setShowMore] = useState(false) const navRef = useRef(null) - const handleTriggerMore = useCallback(() => { - setShowMore(true) - }, [setShowMore]) const primaryOps = useMemo(() => { if (operations) @@ -169,43 +172,44 @@ const AppOperations = ({ ))} {shouldShowMoreButton && ( - - - - - -
- {moreOperations.map(item => item.type === 'divider' - ? ( -
- ) - : ( -
- {cloneElement(item.icon, { className: 'h-4 w-4 text-text-tertiary' })} - {item.title} -
- ))} -
- - + + + + {moreOperations.map(item => item.type === 'divider' + ? ( + + ) + : ( + + {cloneElement(item.icon, { className: 'h-4 w-4 text-text-tertiary' })} + {item.title} + + ))} + + )}
diff --git a/web/app/components/app-sidebar/app-sidebar-dropdown.tsx b/web/app/components/app-sidebar/app-sidebar-dropdown.tsx index 361fc94d69..617d14f426 100644 --- a/web/app/components/app-sidebar/app-sidebar-dropdown.tsx +++ b/web/app/components/app-sidebar/app-sidebar-dropdown.tsx @@ -5,14 +5,14 @@ import { RiMenuLine, } from '@remixicon/react' import * as React from 'react' -import { useCallback, useRef, useState } from 'react' +import { useState } from 'react' import { useTranslation } from 'react-i18next' import { useStore as useAppStore } from '@/app/components/app/store' import { - PortalToFollowElem, - PortalToFollowElemContent, - PortalToFollowElemTrigger, -} from '@/app/components/base/portal-to-follow-elem' + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, +} from '@/app/components/base/ui/dropdown-menu' import { useAppContext } from '@/context/app-context' import AppIcon from '../base/app-icon' import Divider from '../base/divider' @@ -34,16 +34,7 @@ const AppSidebarDropdown = ({ navigation }: Props) => { const { isCurrentWorkspaceEditor } = useAppContext() const appDetail = useAppStore(state => state.appDetail) const [detailExpand, setDetailExpand] = useState(false) - - const [open, doSetOpen] = useState(false) - const openRef = useRef(open) - const setOpen = useCallback((v: boolean) => { - doSetOpen(v) - openRef.current = v - }, [doSetOpen]) - const handleTrigger = useCallback(() => { - setOpen(!openRef.current) - }, [setOpen]) + const [open, setOpen] = useState(false) if (!appDetail) return null @@ -51,27 +42,28 @@ const AppSidebarDropdown = ({ navigation }: Props) => { return ( <>
- - -
- - -
-
- + + + + + +
{ })}
- - + +
diff --git a/web/app/components/app-sidebar/dataset-info/__tests__/dropdown-callbacks.spec.tsx b/web/app/components/app-sidebar/dataset-info/__tests__/dropdown-callbacks.spec.tsx index 6ed10609e9..b514b6e095 100644 --- a/web/app/components/app-sidebar/dataset-info/__tests__/dropdown-callbacks.spec.tsx +++ b/web/app/components/app-sidebar/dataset-info/__tests__/dropdown-callbacks.spec.tsx @@ -137,14 +137,6 @@ vi.mock('@/app/components/datasets/rename-modal', () => ({ }, })) -vi.mock('@/app/components/base/portal-to-follow-elem', () => ({ - PortalToFollowElem: ({ children }: { children: React.ReactNode }) =>
{children}
, - PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick?: () => void }) => ( -
{children}
- ), - PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) =>
{children}
, -})) - describe('Dropdown callback coverage', () => { beforeEach(() => { vi.clearAllMocks() @@ -159,7 +151,7 @@ describe('Dropdown callback coverage', () => { const user = userEvent.setup() render() - await user.click(screen.getByTestId('portal-trigger')) + await user.click(screen.getByRole('button')) await user.click(screen.getByText('common.operation.edit')) expect(screen.getByTestId('rename-modal')).toBeInTheDocument() @@ -175,7 +167,7 @@ describe('Dropdown callback coverage', () => { const user = userEvent.setup() render() - await user.click(screen.getByTestId('portal-trigger')) + await user.click(screen.getByRole('button')) await user.click(screen.getByText('common.operation.edit')) expect(screen.getByTestId('rename-modal')).toBeInTheDocument() @@ -190,7 +182,7 @@ describe('Dropdown callback coverage', () => { const user = userEvent.setup() render() - await user.click(screen.getByTestId('portal-trigger')) + await user.click(screen.getByRole('button')) await user.click(screen.getByText('common.operation.delete')) await waitFor(() => { @@ -210,7 +202,7 @@ describe('Dropdown callback coverage', () => { render() - await user.click(screen.getByTestId('portal-trigger')) + await user.click(screen.getByRole('button')) await user.click(screen.getByText('common.operation.delete')) await waitFor(() => { @@ -224,7 +216,7 @@ describe('Dropdown callback coverage', () => { render() - await user.click(screen.getByTestId('portal-trigger')) + await user.click(screen.getByRole('button')) await user.click(screen.getByText('datasetPipeline.operations.exportPipeline')) await waitFor(() => { @@ -232,6 +224,27 @@ describe('Dropdown callback coverage', () => { }) }) + it('should not attempt export when the dataset has no pipeline id', async () => { + const user = userEvent.setup() + mockDataset = createDataset({ pipeline_id: '' }) + + render() + + await user.click(screen.getByRole('button')) + await user.click(screen.getByText('datasetPipeline.operations.exportPipeline')) + + expect(mockExportPipeline).not.toHaveBeenCalled() + }) + + it('should render and open correctly when collapsed', async () => { + const user = userEvent.setup() + render() + + await user.click(screen.getByRole('button')) + + expect(screen.getByText('common.operation.edit')).toBeInTheDocument() + }) + it('should surface the backend message when checking app usage fails', async () => { const user = userEvent.setup() mockCheckIsUsedInApp.mockRejectedValueOnce({ @@ -240,7 +253,7 @@ describe('Dropdown callback coverage', () => { render() - await user.click(screen.getByTestId('portal-trigger')) + await user.click(screen.getByRole('button')) await user.click(screen.getByText('common.operation.delete')) await waitFor(() => { diff --git a/web/app/components/app-sidebar/dataset-info/__tests__/index.spec.tsx b/web/app/components/app-sidebar/dataset-info/__tests__/index.spec.tsx index bb85e00c14..e6d3f94e2a 100644 --- a/web/app/components/app-sidebar/dataset-info/__tests__/index.spec.tsx +++ b/web/app/components/app-sidebar/dataset-info/__tests__/index.spec.tsx @@ -1,5 +1,4 @@ import type { DataSet } from '@/models/datasets' -import { RiEditLine } from '@remixicon/react' import { createEvent, fireEvent, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import * as React from 'react' @@ -22,6 +21,7 @@ const mockInvalidDatasetDetail = vi.fn() const mockExportPipeline = vi.fn() const mockCheckIsUsedInApp = vi.fn() const mockDeleteDataset = vi.fn() +const TestEditIcon = () => const createDataset = (overrides: Partial = {}): DataSet => ({ id: 'dataset-1', @@ -210,7 +210,7 @@ describe('MenuItem', () => { const user = userEvent.setup() const handleClick = vi.fn() // Arrange - render() + render() // Act await user.click(screen.getByText('Edit')) @@ -225,7 +225,7 @@ describe('MenuItem', () => { render(
- +
, ) @@ -236,7 +236,7 @@ describe('MenuItem', () => { }) it('should not crash when no click handler is provided', () => { - render() + render() const event = createEvent.click(screen.getByText('Edit')) fireEvent(screen.getByText('Edit'), event) diff --git a/web/app/components/app-sidebar/dataset-info/dropdown.tsx b/web/app/components/app-sidebar/dataset-info/dropdown.tsx index 9abebfcc88..e69b3d7e32 100644 --- a/web/app/components/app-sidebar/dataset-info/dropdown.tsx +++ b/web/app/components/app-sidebar/dataset-info/dropdown.tsx @@ -1,6 +1,5 @@ import type { DataSet } from '@/models/datasets' import { cn } from '@langgenius/dify-ui/cn' -import { RiMoreFill } from '@remixicon/react' import * as React from 'react' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -14,7 +13,6 @@ import { useInvalid } from '@/service/use-base' import { useExportPipelineDSL } from '@/service/use-pipeline' import { downloadBlob } from '@/utils/download' import ActionButton from '../../base/action-button' -import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '../../base/portal-to-follow-elem' import { AlertDialog, AlertDialogActions, @@ -24,6 +22,11 @@ import { AlertDialogDescription, AlertDialogTitle, } from '../../base/ui/alert-dialog' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, +} from '../../base/ui/dropdown-menu' import RenameDatasetModal from '../../datasets/rename-modal' import Menu from './menu' @@ -44,10 +47,6 @@ const DropDown = ({ const isCurrentWorkspaceDatasetOperator = useAppContextWithSelector(state => state.isCurrentWorkspaceDatasetOperator) const dataset = useDatasetDetailContextWithSelector(state => state.dataset) as DataSet - const handleTrigger = useCallback(() => { - setOpen(prev => !prev) - }, []) - const invalidDatasetList = useInvalidDatasetList() const invalidDatasetDetail = useInvalid([...datasetDetailQueryKeyPrefix, dataset.id]) @@ -57,9 +56,11 @@ const DropDown = ({ }, [invalidDatasetDetail, invalidDatasetList]) const openRenameModal = useCallback(() => { - setShowRenameModal(true) - handleTrigger() - }, [handleTrigger]) + setOpen(false) + queueMicrotask(() => { + setShowRenameModal(true) + }) + }, []) const { mutateAsync: exportPipelineConfig } = useExportPipelineDSL() @@ -67,7 +68,7 @@ const DropDown = ({ const { pipeline_id, name } = dataset if (!pipeline_id) return - handleTrigger() + setOpen(false) try { const { data } = await exportPipelineConfig({ pipelineId: pipeline_id, @@ -79,9 +80,10 @@ const DropDown = ({ catch { toast(t('exportFailed', { ns: 'app' }), { type: 'error' }) } - }, [dataset, exportPipelineConfig, handleTrigger, t]) + }, [dataset, exportPipelineConfig, t]) const detectIsUsedByApp = useCallback(async () => { + setOpen(false) try { const { is_using: isUsedByApp } = await checkIsUsedInApp(dataset.id) setConfirmMessage(isUsedByApp ? t('datasetUsedByApp', { ns: 'dataset' })! : t('deleteDatasetConfirmContent', { ns: 'dataset' })!) @@ -91,10 +93,7 @@ const DropDown = ({ const res = await e.json() toast(res?.message || 'Unknown error', { type: 'error' }) } - finally { - handleTrigger() - } - }, [dataset.id, handleTrigger, t]) + }, [dataset.id, t]) const onConfirmDelete = useCallback(async () => { try { @@ -109,32 +108,27 @@ const DropDown = ({ }, [dataset.id, replace, invalidDatasetList, t]) return ( - - - - + }> + + - - + + - + {showRenameModal && ( - + ) } diff --git a/web/app/components/app-sidebar/dataset-sidebar-dropdown.tsx b/web/app/components/app-sidebar/dataset-sidebar-dropdown.tsx index de2563b377..3968a0df6f 100644 --- a/web/app/components/app-sidebar/dataset-sidebar-dropdown.tsx +++ b/web/app/components/app-sidebar/dataset-sidebar-dropdown.tsx @@ -5,13 +5,13 @@ import { RiMenuLine, } from '@remixicon/react' import * as React from 'react' -import { useCallback, useRef, useState } from 'react' +import { useState } from 'react' import { useTranslation } from 'react-i18next' import { - PortalToFollowElem, - PortalToFollowElemContent, - PortalToFollowElemTrigger, -} from '@/app/components/base/portal-to-follow-elem' + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, +} from '@/app/components/base/ui/dropdown-menu' import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail' import { useKnowledge } from '@/hooks/use-knowledge' import { DOC_FORM_TEXT } from '@/models/datasets' @@ -41,15 +41,7 @@ const DatasetSidebarDropdown = ({ const { data: relatedApps } = useDatasetRelatedApps(dataset.id) - const [open, doSetOpen] = useState(false) - const openRef = useRef(open) - const setOpen = useCallback((v: boolean) => { - doSetOpen(v) - openRef.current = v - }, [doSetOpen]) - const handleTrigger = useCallback(() => { - setOpen(!openRef.current) - }, [setOpen]) + const [open, setOpen] = useState(false) const iconInfo = dataset.icon_info || { icon: '📙', @@ -66,32 +58,28 @@ const DatasetSidebarDropdown = ({ return ( <>
- - -
- - -
-
- + + + + + +
@@ -155,8 +143,8 @@ const DatasetSidebarDropdown = ({ documentCount={dataset.document_count} />
- - + +
) diff --git a/web/app/components/app/app-publisher/__tests__/publish-with-multiple-model.spec.tsx b/web/app/components/app/app-publisher/__tests__/publish-with-multiple-model.spec.tsx index f476d8b188..465252c6c4 100644 --- a/web/app/components/app/app-publisher/__tests__/publish-with-multiple-model.spec.tsx +++ b/web/app/components/app/app-publisher/__tests__/publish-with-multiple-model.spec.tsx @@ -22,24 +22,57 @@ vi.mock('../../header/account-setting/model-provider-page/model-icon', () => ({ default: ({ modelName }: { modelName: string }) => {modelName}, })) -vi.mock('@/app/components/base/portal-to-follow-elem', async () => { +vi.mock('@/app/components/base/ui/dropdown-menu', async () => { const ReactModule = await vi.importActual('react') - const OpenContext = ReactModule.createContext(false) + const OpenContext = ReactModule.createContext<{ open: boolean, setOpen: (nextOpen: boolean) => void } | null>(null) + + const useOpenContext = () => { + const context = ReactModule.use(OpenContext) + if (!context) + throw new Error('DropdownMenu components must be wrapped in DropdownMenu') + return context + } return { - PortalToFollowElem: ({ children, open }: { children: React.ReactNode, open: boolean }) => ( - + DropdownMenu: ({ children, open, onOpenChange }: { children: React.ReactNode, open: boolean, onOpenChange?: (open: boolean) => void }) => ( +
{children}
), - PortalToFollowElemTrigger: ({ children, onClick, className }: { children: React.ReactNode, onClick?: () => void, className?: string }) => ( -
- {children} -
- ), - PortalToFollowElemContent: ({ children, className }: { children: React.ReactNode, className?: string }) => { - const open = ReactModule.useContext(OpenContext) - return open ?
{children}
: null + DropdownMenuTrigger: ({ + children, + render, + }: { + children: React.ReactNode + render?: React.ReactElement + }) => { + const { open, setOpen } = useOpenContext() + + if (render) { + return ReactModule.cloneElement(render, { + onClick: () => setOpen(!open), + } as Record, children) + } + + return + }, + DropdownMenuContent: ({ children, popupClassName }: { children: React.ReactNode, popupClassName?: string }) => { + const context = useOpenContext() + return context.open ?
{children}
: null + }, + DropdownMenuItem: ({ children, onClick }: { children: React.ReactNode, onClick?: React.MouseEventHandler }) => { + const { setOpen } = useOpenContext() + return ( + + ) }, } }) diff --git a/web/app/components/app/app-publisher/publish-with-multiple-model.tsx b/web/app/components/app/app-publisher/publish-with-multiple-model.tsx index b4ad0423b0..fbff371577 100644 --- a/web/app/components/app/app-publisher/publish-with-multiple-model.tsx +++ b/web/app/components/app/app-publisher/publish-with-multiple-model.tsx @@ -4,12 +4,13 @@ import type { Model, ModelItem } from '@/app/components/header/account-setting/m import { RiArrowDownSLine } from '@remixicon/react' import { useState } from 'react' import { useTranslation } from 'react-i18next' -import { - PortalToFollowElem, - PortalToFollowElemContent, - PortalToFollowElemTrigger, -} from '@/app/components/base/portal-to-follow-elem' import { Button } from '@/app/components/base/ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/app/components/base/ui/dropdown-menu' import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks' import { useProviderContext } from '@/context/provider-context' import ModelIcon from '../../header/account-setting/model-provider-page/model-icon' @@ -50,61 +51,57 @@ const PublishWithMultipleModel: FC = ({ } }) - const handleToggle = () => { - if (validModelConfigs.length) - setOpen(v => !v) - } - - const handleSelect = (item: ModelAndParameter) => { - onSelect(item) - setOpen(false) - } - return ( - - - - - -
-
- {t('publishAs', { ns: 'appDebug' })} -
- { - validModelConfigs.map((item, index) => ( -
handleSelect(item)} - > - - # - {index + 1} - - -
- {item.modelItem.label[language]} -
-
- )) - } + + + +
+ {t('publishAs', { ns: 'appDebug' })}
- - + { + validModelConfigs.map((item, index) => ( + onSelect(item)} + > + + # + {index + 1} + + +
+ {item.modelItem.label[language]} +
+
+ )) + } +
+ ) } diff --git a/web/app/components/app/configuration/debug/debug-with-multiple-model/__tests__/debug-item.spec.tsx b/web/app/components/app/configuration/debug/debug-with-multiple-model/__tests__/debug-item.spec.tsx index 747ea4efcb..1d54ae17bb 100644 --- a/web/app/components/app/configuration/debug/debug-with-multiple-model/__tests__/debug-item.spec.tsx +++ b/web/app/components/app/configuration/debug/debug-with-multiple-model/__tests__/debug-item.spec.tsx @@ -1,7 +1,7 @@ import type { CSSProperties } from 'react' import type { ModelAndParameter } from '../../types' -import type { Item } from '@/app/components/base/dropdown' -import { fireEvent, render, screen } from '@testing-library/react' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import { ModelStatusEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' import { AppModeEnum } from '@/types/app' import DebugItem from '../debug-item' @@ -10,12 +10,6 @@ const mockUseDebugConfigurationContext = vi.fn() const mockUseDebugWithMultipleModelContext = vi.fn() const mockUseProviderContext = vi.fn() -let capturedDropdownProps: { - onSelect: (item: Item) => void - items: Item[] - secondItems?: Item[] -} | null = null - let capturedModelParameterTriggerProps: { modelAndParameter: ModelAndParameter } | null = null @@ -51,34 +45,6 @@ vi.mock('../model-parameter-trigger', () => ({ }, })) -vi.mock('@/app/components/base/dropdown', () => ({ - default: (props: { onSelect: (item: Item) => void, items: Item[], secondItems?: Item[] }) => { - capturedDropdownProps = props - return ( -
- {props.items.map(item => ( - - ))} - {props.secondItems?.map(item => ( - - ))} -
- ) - }, -})) - const createModelAndParameter = (overrides: Partial = {}): ModelAndParameter => ({ id: 'model-1', model: 'gpt-3.5-turbo', @@ -117,7 +83,6 @@ const renderComponent = (props: Partial = {}) => { describe('DebugItem', () => { beforeEach(() => { vi.clearAllMocks() - capturedDropdownProps = null capturedModelParameterTriggerProps = null mockUseDebugConfigurationContext.mockReturnValue({ @@ -137,12 +102,18 @@ describe('DebugItem', () => { }) }) + const openMenu = async () => { + const user = userEvent.setup() + await user.click(screen.getByRole('button')) + return user + } + describe('rendering', () => { it('should render with basic props', () => { renderComponent() - expect(screen.getByTestId('model-parameter-trigger'))!.toBeInTheDocument() - expect(screen.getByTestId('dropdown'))!.toBeInTheDocument() + expect(screen.getByTestId('model-parameter-trigger')).toBeInTheDocument() + expect(screen.getByRole('button')).toBeInTheDocument() }) it('should display correct index number', () => { @@ -280,7 +251,7 @@ describe('DebugItem', () => { }) describe('dropdown menu', () => { - it('should show duplicate option when less than 4 models', () => { + it('should show duplicate option when less than 4 models', async () => { mockUseDebugWithMultipleModelContext.mockReturnValue({ multipleModelConfigs: [createModelAndParameter()], onMultipleModelConfigsChange: vi.fn(), @@ -288,13 +259,12 @@ describe('DebugItem', () => { }) renderComponent() + await openMenu() - expect(capturedDropdownProps?.items).toContainEqual( - expect.objectContaining({ value: 'duplicate' }), - ) + expect(screen.getByText('appDebug.duplicateModel')).toBeInTheDocument() }) - it('should hide duplicate option when 4 or more models', () => { + it('should hide duplicate option when 4 or more models', async () => { mockUseDebugWithMultipleModelContext.mockReturnValue({ multipleModelConfigs: [ createModelAndParameter({ id: '1' }), @@ -307,52 +277,48 @@ describe('DebugItem', () => { }) renderComponent() + await openMenu() - expect(capturedDropdownProps?.items).not.toContainEqual( - expect.objectContaining({ value: 'duplicate' }), - ) + expect(screen.queryByText('appDebug.duplicateModel')).not.toBeInTheDocument() }) - it('should show debug-as-single-model option when provider and model are set', () => { + it('should show debug-as-single-model option when provider and model are set', async () => { renderComponent({ modelAndParameter: createModelAndParameter({ provider: 'openai', model: 'gpt-3.5-turbo', }), }) + await openMenu() - expect(capturedDropdownProps?.items).toContainEqual( - expect.objectContaining({ value: 'debug-as-single-model' }), - ) + expect(screen.getByText('appDebug.debugAsSingleModel')).toBeInTheDocument() }) - it('should hide debug-as-single-model option when provider is missing', () => { + it('should hide debug-as-single-model option when provider is missing', async () => { renderComponent({ modelAndParameter: createModelAndParameter({ provider: '', model: 'gpt-3.5-turbo', }), }) + await openMenu() - expect(capturedDropdownProps?.items).not.toContainEqual( - expect.objectContaining({ value: 'debug-as-single-model' }), - ) + expect(screen.queryByText('appDebug.debugAsSingleModel')).not.toBeInTheDocument() }) - it('should hide debug-as-single-model option when model is missing', () => { + it('should hide debug-as-single-model option when model is missing', async () => { renderComponent({ modelAndParameter: createModelAndParameter({ provider: 'openai', model: '', }), }) + await openMenu() - expect(capturedDropdownProps?.items).not.toContainEqual( - expect.objectContaining({ value: 'debug-as-single-model' }), - ) + expect(screen.queryByText('appDebug.debugAsSingleModel')).not.toBeInTheDocument() }) - it('should show remove option in secondItems when more than 2 models', () => { + it('should show remove option in secondItems when more than 2 models', async () => { mockUseDebugWithMultipleModelContext.mockReturnValue({ multipleModelConfigs: [ createModelAndParameter({ id: '1' }), @@ -364,13 +330,12 @@ describe('DebugItem', () => { }) renderComponent() + await openMenu() - expect(capturedDropdownProps?.secondItems).toContainEqual( - expect.objectContaining({ value: 'remove' }), - ) + expect(screen.getByText('common.operation.remove')).toBeInTheDocument() }) - it('should not show remove option when 2 or fewer models', () => { + it('should not show remove option when 2 or fewer models', async () => { mockUseDebugWithMultipleModelContext.mockReturnValue({ multipleModelConfigs: [ createModelAndParameter({ id: '1' }), @@ -381,13 +346,14 @@ describe('DebugItem', () => { }) renderComponent() + await openMenu() - expect(capturedDropdownProps?.secondItems).toBeUndefined() + expect(screen.queryByText('common.operation.remove')).not.toBeInTheDocument() }) }) describe('dropdown actions', () => { - it('should duplicate model when duplicate is selected', () => { + it('should duplicate model when duplicate is selected', async () => { const onMultipleModelConfigsChange = vi.fn() const originalModel = createModelAndParameter({ id: 'original' }) @@ -399,7 +365,8 @@ describe('DebugItem', () => { renderComponent({ modelAndParameter: originalModel }) - fireEvent.click(screen.getByTestId('dropdown-item-duplicate')) + const user = await openMenu() + await user.click(screen.getByText('appDebug.duplicateModel')) expect(onMultipleModelConfigsChange).toHaveBeenCalledWith( true, @@ -414,7 +381,7 @@ describe('DebugItem', () => { ) }) - it('should not duplicate when already at 4 models', () => { + it('should not duplicate when already at 4 models', async () => { const onMultipleModelConfigsChange = vi.fn() const models = [ createModelAndParameter({ id: '1' }), @@ -430,14 +397,13 @@ describe('DebugItem', () => { }) renderComponent({ modelAndParameter: models[0] }) - - // Since duplicate is not shown when >= 4 models, we need to manually call handleSelect - capturedDropdownProps?.onSelect({ value: 'duplicate', text: 'Duplicate' }) + await openMenu() expect(onMultipleModelConfigsChange).not.toHaveBeenCalled() + expect(screen.queryByText('appDebug.duplicateModel')).not.toBeInTheDocument() }) - it('should call onDebugWithMultipleModelChange when debug-as-single-model is selected', () => { + it('should call onDebugWithMultipleModelChange when debug-as-single-model is selected', async () => { const onDebugWithMultipleModelChange = vi.fn() const modelAndParameter = createModelAndParameter() @@ -449,12 +415,13 @@ describe('DebugItem', () => { renderComponent({ modelAndParameter }) - fireEvent.click(screen.getByTestId('dropdown-item-debug-as-single-model')) + const user = await openMenu() + await user.click(screen.getByText('appDebug.debugAsSingleModel')) expect(onDebugWithMultipleModelChange).toHaveBeenCalledWith(modelAndParameter) }) - it('should remove model when remove is selected', () => { + it('should remove model when remove is selected', async () => { const onMultipleModelConfigsChange = vi.fn() const models = [ createModelAndParameter({ id: '1' }), @@ -470,7 +437,8 @@ describe('DebugItem', () => { renderComponent({ modelAndParameter: models[1] }) - fireEvent.click(screen.getByTestId('dropdown-second-item-remove')) + const user = await openMenu() + await user.click(screen.getByText('common.operation.remove')) expect(onMultipleModelConfigsChange).toHaveBeenCalledWith( true, @@ -478,7 +446,7 @@ describe('DebugItem', () => { ) }) - it('should insert duplicated model at correct position', () => { + it('should insert duplicated model at correct position', async () => { const onMultipleModelConfigsChange = vi.fn() const models = [ createModelAndParameter({ id: '1' }), @@ -495,7 +463,8 @@ describe('DebugItem', () => { // Duplicate the second model renderComponent({ modelAndParameter: models[1] }) - fireEvent.click(screen.getByTestId('dropdown-item-duplicate')) + const user = await openMenu() + await user.click(screen.getByText('appDebug.duplicateModel')) expect(onMultipleModelConfigsChange).toHaveBeenCalledWith( true, diff --git a/web/app/components/app/configuration/debug/debug-with-multiple-model/debug-item.tsx b/web/app/components/app/configuration/debug/debug-with-multiple-model/debug-item.tsx index 7693c3ab43..2e535baeac 100644 --- a/web/app/components/app/configuration/debug/debug-with-multiple-model/debug-item.tsx +++ b/web/app/components/app/configuration/debug/debug-with-multiple-model/debug-item.tsx @@ -1,9 +1,15 @@ import type { CSSProperties, FC } from 'react' import type { ModelAndParameter } from '../types' -import type { Item } from '@/app/components/base/dropdown' -import { memo } from 'react' +import { memo, useState } from 'react' import { useTranslation } from 'react-i18next' -import Dropdown from '@/app/components/base/dropdown' +import ActionButton from '@/app/components/base/action-button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/app/components/base/ui/dropdown-menu' import { ModelStatusEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' import { useDebugConfigurationContext } from '@/context/debug-configuration' import { useProviderContext } from '@/context/provider-context' @@ -35,34 +41,43 @@ const DebugItem: FC = ({ const index = multipleModelConfigs.findIndex(v => v.id === modelAndParameter.id) const currentProvider = textGenerationModelList.find(item => item.provider === modelAndParameter.provider) const currentModel = currentProvider?.models.find(item => item.model === modelAndParameter.model) + const [open, setOpen] = useState(false) - const handleSelect = (item: Item) => { - if (item.value === 'duplicate') { - if (multipleModelConfigs.length >= 4) - return + const handleDuplicate = () => { + setOpen(false) + if (multipleModelConfigs.length >= 4) + return - onMultipleModelConfigsChange( - true, - [ - ...multipleModelConfigs.slice(0, index + 1), - { - ...modelAndParameter, - id: `${Date.now()}`, - }, - ...multipleModelConfigs.slice(index + 1), - ], - ) - } - if (item.value === 'debug-as-single-model') - onDebugWithMultipleModelChange(modelAndParameter) - if (item.value === 'remove') { - onMultipleModelConfigsChange( - true, - multipleModelConfigs.filter(item => item.id !== modelAndParameter.id), - ) - } + onMultipleModelConfigsChange( + true, + [ + ...multipleModelConfigs.slice(0, index + 1), + { + ...modelAndParameter, + id: `${Date.now()}`, + }, + ...multipleModelConfigs.slice(index + 1), + ], + ) } + const handleDebugAsSingleModel = () => { + setOpen(false) + onDebugWithMultipleModelChange(modelAndParameter) + } + + const handleRemove = () => { + setOpen(false) + onMultipleModelConfigsChange( + true, + multipleModelConfigs.filter(item => item.id !== modelAndParameter.id), + ) + } + + const showDuplicate = multipleModelConfigs.length <= 3 + const showDebugAsSingleModel = !!(modelAndParameter.provider && modelAndParameter.model) + const showRemove = multipleModelConfigs.length > 2 + return (
= ({ - 2 - ? [ - { - value: 'remove', - text: t('operation.remove', { ns: 'common' }) as string, - }, - ] - : undefined - } - /> + + }> + + + + + + {showDuplicate && ( + + {t('duplicateModel', { ns: 'appDebug' })} + + )} + {showDebugAsSingleModel && ( + + {t('debugAsSingleModel', { ns: 'appDebug' })} + + )} + {showRemove && ( + <> + {(showDuplicate || showDebugAsSingleModel) && } + + {t('operation.remove', { ns: 'common' })} + + + )} + +
{ diff --git a/web/app/components/base/action-button/index.tsx b/web/app/components/base/action-button/index.tsx index ec84fa06d8..86c8933a8a 100644 --- a/web/app/components/base/action-button/index.tsx +++ b/web/app/components/base/action-button/index.tsx @@ -30,7 +30,7 @@ const actionButtonVariants = cva( }, ) -export type ActionButtonProps = { +type ActionButtonProps = { size?: 'xs' | 's' | 'm' | 'l' | 'xl' state?: ActionButtonState styleCss?: CSSProperties @@ -73,4 +73,4 @@ const ActionButton = ({ className, size, state = ActionButtonState.Default, styl ActionButton.displayName = 'ActionButton' export default ActionButton -export { ActionButton, ActionButtonState, actionButtonVariants } +export { ActionButton, ActionButtonState } diff --git a/web/app/components/base/chat/chat-with-history/__tests__/header-in-mobile.spec.tsx b/web/app/components/base/chat/chat-with-history/__tests__/header-in-mobile.spec.tsx index a183a7670b..af58a29fcc 100644 --- a/web/app/components/base/chat/chat-with-history/__tests__/header-in-mobile.spec.tsx +++ b/web/app/components/base/chat/chat-with-history/__tests__/header-in-mobile.spec.tsx @@ -211,8 +211,9 @@ describe('HeaderInMobile', () => { fireEvent.click(screen.getByText(/share\.chat\.viewChatSettings/i)) // Check if chat settings overlay is open - // Check if chat settings overlay is open - expect(screen.getByTestId('mobile-chat-settings-overlay'))!.toBeInTheDocument() + await waitFor(() => { + expect(screen.getByTestId('mobile-chat-settings-overlay')).toBeInTheDocument() + }) // Close chat settings via overlay click fireEvent.click(screen.getByTestId('mobile-chat-settings-overlay')) @@ -236,7 +237,9 @@ describe('HeaderInMobile', () => { }) fireEvent.click(screen.getByText(/share\.chat\.viewChatSettings/i)) - expect(screen.getByTestId('mobile-chat-settings-overlay'))!.toBeInTheDocument() + await waitFor(() => { + expect(screen.getByTestId('mobile-chat-settings-overlay')).toBeInTheDocument() + }) // Click inside the settings panel (find the title) const settingsTitle = screen.getByText(/share\.chat\.chatSettingsTitle/i) @@ -282,7 +285,9 @@ describe('HeaderInMobile', () => { }) fireEvent.click(screen.getByText(/share\.chat\.resetChat/i)) - expect(handleNewConversation).toHaveBeenCalled() + await waitFor(() => { + expect(handleNewConversation).toHaveBeenCalled() + }) }) it('should handle pin conversation', async () => { @@ -348,8 +353,7 @@ describe('HeaderInMobile', () => { fireEvent.click(screen.getByText(/explore\.sidebar\.action\.rename/i)) // RenameModal should be visible - // RenameModal should be visible - expect(screen.getByRole('dialog'))!.toBeInTheDocument() + expect(await screen.findByRole('dialog')).toBeInTheDocument() const input = screen.getByDisplayValue('Conv 1') fireEvent.change(input, { target: { value: 'New Name' } }) @@ -377,8 +381,7 @@ describe('HeaderInMobile', () => { fireEvent.click(screen.getByText(/explore\.sidebar\.action\.rename/i)) // RenameModal should be visible - // RenameModal should be visible - expect(screen.getByRole('dialog'))!.toBeInTheDocument() + expect(await screen.findByRole('dialog')).toBeInTheDocument() // Click cancel button const cancelButton = screen.getByRole('button', { name: /common\.operation\.cancel/i }) @@ -410,8 +413,7 @@ describe('HeaderInMobile', () => { fireEvent.click(screen.getByText(/explore\.sidebar\.action\.rename/i)) // RenameModal should be visible with loading state - // RenameModal should be visible with loading state - expect(screen.getByRole('dialog'))!.toBeInTheDocument() + expect(await screen.findByRole('dialog')).toBeInTheDocument() }) it('should handle delete conversation', async () => { diff --git a/web/app/components/base/chat/chat-with-history/header/__tests__/mobile-operation-dropdown.spec.tsx b/web/app/components/base/chat/chat-with-history/header/__tests__/mobile-operation-dropdown.spec.tsx index 295bebecac..525f9b89a5 100644 --- a/web/app/components/base/chat/chat-with-history/header/__tests__/mobile-operation-dropdown.spec.tsx +++ b/web/app/components/base/chat/chat-with-history/header/__tests__/mobile-operation-dropdown.spec.tsx @@ -1,4 +1,4 @@ -import { render, screen } from '@testing-library/react' +import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import * as React from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -53,11 +53,16 @@ describe('MobileOperationDropdown Component', () => { // Reset Chat await user.click(screen.getByText('share.chat.resetChat')) - expect(defaultProps.handleResetChat).toHaveBeenCalledTimes(1) + await waitFor(() => { + expect(defaultProps.handleResetChat).toHaveBeenCalledTimes(1) + }) + await user.click(screen.getByRole('button')) // View Chat Settings await user.click(screen.getByText('share.chat.viewChatSettings')) - expect(defaultProps.handleViewChatSettings).toHaveBeenCalledTimes(1) + await waitFor(() => { + expect(defaultProps.handleViewChatSettings).toHaveBeenCalledTimes(1) + }) }) it('applies hover state to ActionButton when open', async () => { @@ -72,4 +77,16 @@ describe('MobileOperationDropdown Component', () => { await user.click(trigger) expect(trigger).toHaveClass('action-btn-hover') }) + + it('closes the menu after clicking an action', async () => { + const user = userEvent.setup() + render() + + await user.click(screen.getByRole('button')) + await user.click(screen.getByText('share.chat.resetChat')) + + await waitFor(() => { + expect(screen.queryByText('share.chat.resetChat')).not.toBeInTheDocument() + }) + }) }) diff --git a/web/app/components/base/chat/chat-with-history/header/__tests__/operation.spec.tsx b/web/app/components/base/chat/chat-with-history/header/__tests__/operation.spec.tsx index 454f20066e..294d5eebc5 100644 --- a/web/app/components/base/chat/chat-with-history/header/__tests__/operation.spec.tsx +++ b/web/app/components/base/chat/chat-with-history/header/__tests__/operation.spec.tsx @@ -1,4 +1,4 @@ -import { render, screen } from '@testing-library/react' +import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import * as React from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -74,12 +74,18 @@ describe('Operation Component', () => { expect(defaultProps.togglePin).toHaveBeenCalledTimes(1) // Rename + await user.click(screen.getByText('Chat Title')) await user.click(screen.getByText('explore.sidebar.action.rename')) - expect(defaultProps.onRenameConversation).toHaveBeenCalledTimes(1) + await waitFor(() => { + expect(defaultProps.onRenameConversation).toHaveBeenCalledTimes(1) + }) // Delete + await user.click(screen.getByText('Chat Title')) await user.click(screen.getByText('explore.sidebar.action.delete')) - expect(defaultProps.onDelete).toHaveBeenCalledTimes(1) + await waitFor(() => { + expect(defaultProps.onDelete).toHaveBeenCalledTimes(1) + }) }) it('applies hover background when open', async () => { diff --git a/web/app/components/base/chat/chat-with-history/header/mobile-operation-dropdown.tsx b/web/app/components/base/chat/chat-with-history/header/mobile-operation-dropdown.tsx index 43cf25908c..5d80db7ac3 100644 --- a/web/app/components/base/chat/chat-with-history/header/mobile-operation-dropdown.tsx +++ b/web/app/components/base/chat/chat-with-history/header/mobile-operation-dropdown.tsx @@ -1,7 +1,12 @@ -import { useState } from 'react' +import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import ActionButton, { ActionButtonState } from '@/app/components/base/action-button' -import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/app/components/base/ui/dropdown-menu' type Props = { handleResetChat: () => void @@ -16,40 +21,45 @@ const MobileOperationDropdown = ({ }: Props) => { const { t } = useTranslation() const [open, setOpen] = useState(false) + const handleMenuAction = useCallback((callback: () => void) => { + setOpen(false) + queueMicrotask(callback) + }, []) return ( - - setOpen(v => !v)} + } data-testid="mobile-more-btn" >
- - -
+ + handleMenuAction(handleResetChat)} > -
- {t('chat.resetChat', { ns: 'share' })} -
- {!hideViewChatSettings && ( -
- {t('chat.viewChatSettings', { ns: 'share' })} -
- )} -
-
- + {t('chat.resetChat', { ns: 'share' })} + + {!hideViewChatSettings && ( + handleMenuAction(handleViewChatSettings)} + > + {t('chat.viewChatSettings', { ns: 'share' })} + + )} + + ) } diff --git a/web/app/components/base/chat/chat-with-history/header/operation.tsx b/web/app/components/base/chat/chat-with-history/header/operation.tsx index d61af1f6a1..d439a43c1f 100644 --- a/web/app/components/base/chat/chat-with-history/header/operation.tsx +++ b/web/app/components/base/chat/chat-with-history/header/operation.tsx @@ -1,14 +1,16 @@ 'use client' -import type { Placement } from '@floating-ui/react' import type { FC } from 'react' +import type { Placement } from '@/app/components/base/ui/placement' import { cn } from '@langgenius/dify-ui/cn' -import { - RiArrowDownSLine, -} from '@remixicon/react' import * as React from 'react' -import { useState } from 'react' +import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' -import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/app/components/base/ui/dropdown-menu' type Props = { title: string @@ -33,42 +35,51 @@ const Operation: FC = ({ }) => { const { t } = useTranslation() const [open, setOpen] = useState(false) + const handleDeferredAction = useCallback((action: () => void) => { + setOpen(false) + queueMicrotask(action) + }, []) return ( - - setOpen(v => !v)} + } >
{title}
- +
-
- -
-
- {isPinned ? t('sidebar.action.unpin', { ns: 'explore' }) : t('sidebar.action.pin', { ns: 'explore' })} -
- {isShowRenameConversation && ( -
- {t('sidebar.action.rename', { ns: 'explore' })} -
- )} - {isShowDelete && ( -
- {t('sidebar.action.delete', { ns: 'explore' })} -
- )} -
-
-
+ + + + {isPinned ? t('sidebar.action.unpin', { ns: 'explore' }) : t('sidebar.action.pin', { ns: 'explore' })} + + {isShowRenameConversation && ( + onRenameConversation && handleDeferredAction(onRenameConversation)} + > + {t('sidebar.action.rename', { ns: 'explore' })} + + )} + {isShowDelete && ( + handleDeferredAction(onDelete)} + > + {t('sidebar.action.delete', { ns: 'explore' })} + + )} + + ) } export default React.memo(Operation) diff --git a/web/app/components/base/chat/chat-with-history/sidebar/__tests__/operation.spec.tsx b/web/app/components/base/chat/chat-with-history/sidebar/__tests__/operation.spec.tsx index e46b54872e..5aa8da7965 100644 --- a/web/app/components/base/chat/chat-with-history/sidebar/__tests__/operation.spec.tsx +++ b/web/app/components/base/chat/chat-with-history/sidebar/__tests__/operation.spec.tsx @@ -1,16 +1,9 @@ -import { render, screen } from '@testing-library/react' +import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import * as React from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' import Operation from '../operation' -// Mock PortalToFollowElem components to render children in place -vi.mock('@/app/components/base/portal-to-follow-elem', () => ({ - PortalToFollowElem: ({ children, open }: { children: React.ReactNode, open: boolean }) =>
{children}
, - PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick?: () => void }) =>
{children}
, - PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) =>
{children}
, -})) - describe('Operation', () => { const defaultProps = { isActive: false, @@ -72,7 +65,9 @@ describe('Operation', () => { await user.click(screen.getByRole('button')) await user.click(screen.getByText('explore.sidebar.action.rename')) - expect(defaultProps.onRenameConversation).toHaveBeenCalled() + await waitFor(() => { + expect(defaultProps.onRenameConversation).toHaveBeenCalled() + }) }) it('should call onDelete when delete is clicked', async () => { @@ -82,7 +77,9 @@ describe('Operation', () => { await user.click(screen.getByRole('button')) await user.click(screen.getByText('explore.sidebar.action.delete')) - expect(defaultProps.onDelete).toHaveBeenCalled() + await waitFor(() => { + expect(defaultProps.onDelete).toHaveBeenCalled() + }) }) it('should respect visibility props', async () => { @@ -108,8 +105,7 @@ describe('Operation', () => { await user.click(screen.getByRole('button')) - const portalContent = screen.getByTestId('portal-content') - expect(portalContent).toBeInTheDocument() + expect(screen.getByText('explore.sidebar.action.pin')).toBeInTheDocument() }) it('should close dropdown when item hovering stops', async () => { @@ -120,5 +116,60 @@ describe('Operation', () => { expect(screen.getByText('explore.sidebar.action.pin')).toBeInTheDocument() rerender() + + await waitFor(() => { + expect(screen.queryByText('explore.sidebar.action.pin')).not.toBeInTheDocument() + }) + }) + + it('should keep the trigger mounted while visually hidden', () => { + render() + + const trigger = screen.getByRole('button') + expect(trigger).toHaveClass('pointer-events-none') + expect(trigger).toHaveClass('opacity-0') + }) + + it('should safely ignore rename clicks when callback is missing', async () => { + const user = userEvent.setup() + render() + + await user.click(screen.getByRole('button')) + await user.click(screen.getByText('explore.sidebar.action.rename')) + + await waitFor(() => { + expect(screen.queryByText('explore.sidebar.action.rename')).not.toBeInTheDocument() + }) + }) + + it('should not bubble trigger clicks to the parent container', async () => { + const user = userEvent.setup() + const parentClick = vi.fn() + + render( +
+ +
, + ) + + await user.click(screen.getByRole('button')) + + expect(parentClick).not.toHaveBeenCalled() + }) + + it('should not bubble popup clicks to the parent container', async () => { + const user = userEvent.setup() + const parentClick = vi.fn() + + render( +
+ +
, + ) + + await user.click(screen.getByRole('button')) + await user.click(screen.getByRole('menu')) + + expect(parentClick).not.toHaveBeenCalled() }) }) diff --git a/web/app/components/base/chat/chat-with-history/sidebar/operation.tsx b/web/app/components/base/chat/chat-with-history/sidebar/operation.tsx index 18ff3e4d62..adda03fb55 100644 --- a/web/app/components/base/chat/chat-with-history/sidebar/operation.tsx +++ b/web/app/components/base/chat/chat-with-history/sidebar/operation.tsx @@ -1,19 +1,17 @@ 'use client' import type { FC } from 'react' import { cn } from '@langgenius/dify-ui/cn' -import { - RiDeleteBinLine, - RiEditLine, - RiMoreFill, - RiPushpinLine, - RiUnpinLine, -} from '@remixicon/react' import { useBoolean } from 'ahooks' import * as React from 'react' -import { useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import ActionButton, { ActionButtonState } from '@/app/components/base/action-button' -import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/app/components/base/ui/dropdown-menu' type Props = { isActive?: boolean @@ -38,24 +36,29 @@ const Operation: FC = ({ }) => { const { t } = useTranslation() const [open, setOpen] = useState(false) - const ref = useRef(null) const [isHovering, { setTrue: setIsHovering, setFalse: setNotHovering }] = useBoolean(false) useEffect(() => { if (!isItemHovering && !isHovering) setOpen(false) }, [isItemHovering, isHovering]) + const handleDeferredAction = useCallback((action?: () => void) => { + if (!action) + return + setOpen(false) + queueMicrotask(action) + }, []) return ( - - setOpen(v => !v)} + } + onClick={e => e.stopPropagation()} > = ({ : ActionButtonState.Default } > - + - - -
+ e.stopPropagation(), + }} + > + { e.stopPropagation() + togglePin() }} > -
- {isPinned && } - {!isPinned && } - {isPinned ? t('sidebar.action.unpin', { ns: 'explore' }) : t('sidebar.action.pin', { ns: 'explore' })} -
- {isShowRenameConversation && ( -
- - {t('sidebar.action.rename', { ns: 'explore' })} -
- )} - {isShowDelete && ( -
- - {t('sidebar.action.delete', { ns: 'explore' })} -
- )} -
-
-
+ {isPinned && } + {!isPinned && } + {isPinned ? t('sidebar.action.unpin', { ns: 'explore' }) : t('sidebar.action.pin', { ns: 'explore' })} + + {isShowRenameConversation && ( + { + e.stopPropagation() + handleDeferredAction(onRenameConversation) + }} + > + + {t('sidebar.action.rename', { ns: 'explore' })} + + )} + {isShowDelete && ( + { + e.stopPropagation() + handleDeferredAction(onDelete) + }} + > + + {t('sidebar.action.delete', { ns: 'explore' })} + + )} + + ) } export default React.memo(Operation) diff --git a/web/app/components/base/dropdown/__tests__/index.spec.tsx b/web/app/components/base/dropdown/__tests__/index.spec.tsx deleted file mode 100644 index 9820554e3d..0000000000 --- a/web/app/components/base/dropdown/__tests__/index.spec.tsx +++ /dev/null @@ -1,225 +0,0 @@ -import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' -import Dropdown from '../index' - -describe('Dropdown Component', () => { - const mockItems = [ - { value: 'option1', text: 'Option 1' }, - { value: 'option2', text: 'Option 2' }, - ] - const mockSecondItems = [ - { value: 'option3', text: 'Option 3' }, - ] - const onSelect = vi.fn() - - afterEach(() => { - cleanup() - vi.clearAllMocks() - }) - - it('renders default trigger properly', () => { - const { container } = render( - , - ) - const trigger = container.querySelector('button') - expect(trigger).toBeInTheDocument() - }) - - it('renders custom trigger when provided', () => { - render( - } - />, - ) - const trigger = screen.getByTestId('custom-trigger') - expect(trigger).toBeInTheDocument() - expect(trigger).toHaveTextContent('Closed') - }) - - it('opens dropdown menu on trigger click and shows items', async () => { - render( - , - ) - const trigger = screen.getByRole('button') - - await act(async () => { - fireEvent.click(trigger) - }) - - // Dropdown items are rendered in a portal (document.body) - expect(screen.getByText('Option 1')).toBeInTheDocument() - expect(screen.getByText('Option 2')).toBeInTheDocument() - }) - - it('calls onSelect and closes dropdown when an item is clicked', async () => { - render( - , - ) - const trigger = screen.getByRole('button') - - await act(async () => { - fireEvent.click(trigger) - }) - - const option1 = screen.getByText('Option 1') - await act(async () => { - fireEvent.click(option1) - }) - - expect(onSelect).toHaveBeenCalledWith(mockItems[0]) - expect(screen.queryByText('Option 1')).not.toBeInTheDocument() - }) - - it('calls onSelect and closes dropdown when a second item is clicked', async () => { - render( - , - ) - - await act(async () => { - fireEvent.click(screen.getByRole('button')) - }) - - const option3 = screen.getByText('Option 3') - await act(async () => { - fireEvent.click(option3) - }) - expect(onSelect).toHaveBeenCalledWith(mockSecondItems[0]) - expect(screen.queryByText('Option 3')).not.toBeInTheDocument() - }) - - it('renders second items and divider when provided', async () => { - render( - , - ) - const trigger = screen.getByRole('button') - - await act(async () => { - fireEvent.click(trigger) - }) - - expect(screen.getByText('Option 1')).toBeInTheDocument() - expect(screen.getByText('Option 3')).toBeInTheDocument() - - // Check for divider (h-px bg-divider-regular) - const divider = document.body.querySelector('.bg-divider-regular.h-px') - expect(divider).toBeInTheDocument() - }) - - it('applies custom classNames', async () => { - const popupClass = 'custom-popup' - const itemClass = 'custom-item' - const secondItemClass = 'custom-second-item' - - render( - , - ) - - await act(async () => { - fireEvent.click(screen.getByRole('button')) - }) - - const popup = document.body.querySelector(`.${popupClass}`) - expect(popup).toBeInTheDocument() - - const items = screen.getAllByText('Option 1') - expect(items[0]).toHaveClass(itemClass) - - const secondItems = screen.getAllByText('Option 3') - expect(secondItems[0]).toHaveClass(secondItemClass) - }) - - it('applies open class to trigger when menu is open', async () => { - render() - const trigger = screen.getByRole('button') - await act(async () => { - fireEvent.click(trigger) - }) - expect(trigger).toHaveClass('bg-divider-regular') - }) - - it('handles JSX elements as item text', async () => { - const itemsWithJSX = [ - { value: 'jsx', text: JSX Content }, - ] - render( - , - ) - - await act(async () => { - fireEvent.click(screen.getByRole('button')) - }) - - expect(screen.getByTestId('jsx-item')).toBeInTheDocument() - expect(screen.getByText('JSX Content')).toBeInTheDocument() - }) - - it('does not render items section if items list is empty', async () => { - render( - , - ) - - await act(async () => { - fireEvent.click(screen.getByRole('button')) - }) - - const p1Divs = document.body.querySelectorAll('.p-1') - expect(p1Divs.length).toBe(1) - expect(screen.queryByText('Option 1')).not.toBeInTheDocument() - expect(screen.getByText('Option 3')).toBeInTheDocument() - }) - - it('does not render divider if only one section is provided', async () => { - const { rerender } = render( - , - ) - await act(async () => { - fireEvent.click(screen.getByRole('button')) - }) - expect(document.body.querySelector('.bg-divider-regular.h-px')).not.toBeInTheDocument() - - await act(async () => { - rerender( - , - ) - }) - expect(document.body.querySelector('.bg-divider-regular.h-px')).not.toBeInTheDocument() - }) - - it('renders nothing if both item lists are empty', async () => { - render() - await act(async () => { - fireEvent.click(screen.getByRole('button')) - }) - const popup = document.body.querySelector('.bg-components-panel-bg') - expect(popup?.children.length).toBe(0) - }) - - it('passes triggerProps to ActionButton and applies custom className', () => { - render( - , - ) - const trigger = screen.getByLabelText('dropdown-trigger') - expect(trigger).toBeDisabled() - expect(trigger).toHaveClass('custom-trigger-class') - }) -}) diff --git a/web/app/components/base/dropdown/index.stories.tsx b/web/app/components/base/dropdown/index.stories.tsx deleted file mode 100644 index 7cb7f820f6..0000000000 --- a/web/app/components/base/dropdown/index.stories.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/nextjs-vite' -import type { Item } from '.' -import { useState } from 'react' -import { fn } from 'storybook/test' -import Dropdown from '.' - -const PRIMARY_ITEMS: Item[] = [ - { value: 'rename', text: 'Rename' }, - { value: 'duplicate', text: 'Duplicate' }, -] - -const SECONDARY_ITEMS: Item[] = [ - { value: 'archive', text: Archive }, - { value: 'delete', text: Delete }, -] - -const meta = { - title: 'Base/Navigation/Dropdown', - component: Dropdown, - parameters: { - docs: { - description: { - component: 'Small contextual menu with optional destructive section. Uses portal positioning utilities for precise placement.', - }, - }, - }, - tags: ['autodocs'], - args: { - items: PRIMARY_ITEMS, - secondItems: SECONDARY_ITEMS, - }, -} satisfies Meta - -export default meta -type Story = StoryObj - -const DropdownDemo = (props: React.ComponentProps) => { - const [lastAction, setLastAction] = useState('None') - - return ( -
- { - setLastAction(String(item.value)) - props.onSelect?.(item) - }} - /> -
- Last action: - {' '} - {lastAction} -
-
- ) -} - -export const Playground: Story = { - render: args => , - args: { - items: PRIMARY_ITEMS, - secondItems: SECONDARY_ITEMS, - onSelect: fn(), - }, -} - -export const CustomTrigger: Story = { - render: args => ( - ( - - )} - /> - ), - args: { - items: PRIMARY_ITEMS, - onSelect: fn(), - }, -} diff --git a/web/app/components/base/dropdown/index.tsx b/web/app/components/base/dropdown/index.tsx deleted file mode 100644 index f9a19f34ea..0000000000 --- a/web/app/components/base/dropdown/index.tsx +++ /dev/null @@ -1,122 +0,0 @@ -import type { FC } from 'react' -import type { ActionButtonProps } from '@/app/components/base/action-button' -import { cn } from '@langgenius/dify-ui/cn' -import { - RiMoreFill, -} from '@remixicon/react' -import { useState } from 'react' -import ActionButton from '@/app/components/base/action-button' -import { - PortalToFollowElem, - PortalToFollowElemContent, - PortalToFollowElemTrigger, -} from '@/app/components/base/portal-to-follow-elem' - -export type Item = { - value: string | number - text: string | React.JSX.Element -} -type DropdownProps = { - items: Item[] - secondItems?: Item[] - onSelect: (item: Item) => void - renderTrigger?: (open: boolean) => React.ReactNode - triggerProps?: ActionButtonProps - popupClassName?: string - itemClassName?: string - secondItemClassName?: string -} -const Dropdown: FC = ({ - items, - onSelect, - secondItems, - renderTrigger, - triggerProps, - popupClassName, - itemClassName, - secondItemClassName, -}) => { - const [open, setOpen] = useState(false) - - const handleSelect = (item: Item) => { - setOpen(false) - onSelect(item) - } - - return ( - - setOpen(v => !v)}> - { - renderTrigger - ? renderTrigger(open) - : ( - - - - ) - } - - -
- { - !!items.length && ( -
- { - items.map(item => ( -
handleSelect(item)} - > - {item.text} -
- )) - } -
- ) - } - { - (!!items.length && !!secondItems?.length) && ( -
- ) - } - { - !!secondItems?.length && ( -
- { - secondItems.map(item => ( -
handleSelect(item)} - > - {item.text} -
- )) - } -
- ) - } -
- - - ) -} - -export default Dropdown diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/__tests__/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/__tests__/index.spec.tsx index 24a241ff93..906a9e01e0 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/__tests__/index.spec.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { fireEvent, render, screen } from '@testing-library/react' import * as React from 'react' import Breadcrumbs from '../index' @@ -44,6 +44,16 @@ const resetMockStoreState = () => { mockStoreState.setBucket = vi.fn() } +const getDropdownTrigger = () => { + return document.querySelector('[aria-haspopup="menu"]') as HTMLElement | null +} + +const openCollapsedBreadcrumbDropdown = () => { + const dropdownTrigger = getDropdownTrigger() + expect(dropdownTrigger).toBeInTheDocument() + fireEvent.click(dropdownTrigger as HTMLElement) +} + describe('Breadcrumbs', () => { beforeEach(() => { vi.clearAllMocks() @@ -437,15 +447,11 @@ describe('Breadcrumbs', () => { render() // Act - Click on dropdown trigger (the ... button) - const dropdownTrigger = screen.getAllByRole('button').find(btn => btn.querySelector('svg')) - if (dropdownTrigger) - fireEvent.click(dropdownTrigger) + openCollapsedBreadcrumbDropdown() // Assert - Collapsed breadcrumbs should be visible - await waitFor(() => { - expect(screen.getByText('folder3'))!.toBeInTheDocument() - expect(screen.getByText('folder4'))!.toBeInTheDocument() - }) + expect(await screen.findByText('folder3')).toBeInTheDocument() + expect(await screen.findByText('folder4')).toBeInTheDocument() }) }) }) @@ -615,9 +621,7 @@ describe('Breadcrumbs', () => { // Assert - Should collapse because 3 > 2 // Dropdown should be present - const buttons = screen.getAllByRole('button') - const hasDropdownTrigger = buttons.some(btn => btn.querySelector('svg')) - expect(hasDropdownTrigger).toBe(true) + expect(getDropdownTrigger()).toBeInTheDocument() }) it('should use displayBreadcrumbNum=3 when isInPipeline is false', () => { @@ -647,9 +651,7 @@ describe('Breadcrumbs', () => { render() // Assert - Should collapse because 3 > 2 - const buttons = screen.getAllByRole('button') - const hasDropdownTrigger = buttons.some(btn => btn.querySelector('svg')) - expect(hasDropdownTrigger).toBe(true) + expect(getDropdownTrigger()).toBeInTheDocument() }) }) }) @@ -722,23 +724,16 @@ describe('Breadcrumbs', () => { render() // Act - Click dropdown to see collapsed items - const dropdownTrigger = screen.getAllByRole('button').find(btn => btn.querySelector('svg')) - if (dropdownTrigger) - fireEvent.click(dropdownTrigger) + openCollapsedBreadcrumbDropdown() // prefixBreadcrumbs = ['f1', 'f2'] // collapsedBreadcrumbs = ['f3', 'f4'] // lastBreadcrumb = 'f5' - // prefixBreadcrumbs = ['f1', 'f2'] - // collapsedBreadcrumbs = ['f3', 'f4'] - // lastBreadcrumb = 'f5' - expect(screen.getByText('f1'))!.toBeInTheDocument() - expect(screen.getByText('f2'))!.toBeInTheDocument() - expect(screen.getByText('f5'))!.toBeInTheDocument() - await waitFor(() => { - expect(screen.getByText('f3'))!.toBeInTheDocument() - expect(screen.getByText('f4'))!.toBeInTheDocument() - }) + expect(screen.getByText('f1')).toBeInTheDocument() + expect(screen.getByText('f2')).toBeInTheDocument() + expect(screen.getByText('f5')).toBeInTheDocument() + expect(await screen.findByText('f3')).toBeInTheDocument() + expect(await screen.findByText('f4')).toBeInTheDocument() }) it('should not collapse when breadcrumbs.length <= displayBreadcrumbNum', () => { @@ -883,15 +878,8 @@ describe('Breadcrumbs', () => { render() // Act - Open dropdown and click on collapsed breadcrumb (f3, index=2) - const dropdownTrigger = screen.getAllByRole('button').find(btn => btn.querySelector('svg')) - if (dropdownTrigger) - fireEvent.click(dropdownTrigger) - - await waitFor(() => { - expect(screen.getByText('f3'))!.toBeInTheDocument() - }) - - fireEvent.click(screen.getByText('f3')) + openCollapsedBreadcrumbDropdown() + fireEvent.click(await screen.findByText('f3')) // Assert - Should slice to index 2 + 1 = 3 expect(mockStoreState.setBreadcrumbs).toHaveBeenCalledWith(['f1', 'f2', 'f3']) @@ -954,18 +942,13 @@ describe('Breadcrumbs', () => { render() // Act - Open dropdown - const dropdownTrigger = screen.getAllByRole('button').find(btn => btn.querySelector('svg')) - if (dropdownTrigger) - fireEvent.click(dropdownTrigger) + openCollapsedBreadcrumbDropdown() // Assert - First, last, and collapsed should be accessible - // Assert - First, last, and collapsed should be accessible - expect(screen.getByText('folder-0'))!.toBeInTheDocument() - expect(screen.getByText('folder-1'))!.toBeInTheDocument() - expect(screen.getByText('folder-19'))!.toBeInTheDocument() - await waitFor(() => { - expect(screen.getByText('folder-2'))!.toBeInTheDocument() - }) + expect(screen.getByText('folder-0')).toBeInTheDocument() + expect(screen.getByText('folder-1')).toBeInTheDocument() + expect(screen.getByText('folder-19')).toBeInTheDocument() + expect(await screen.findByText('folder-2')).toBeInTheDocument() }) it('should handle empty bucket string', () => { @@ -1026,9 +1009,7 @@ describe('Breadcrumbs', () => { render() // Assert - Should collapse because breadcrumbs.length > expectedNum - const buttons = screen.getAllByRole('button') - const hasDropdownTrigger = buttons.some(btn => btn.querySelector('svg')) - expect(hasDropdownTrigger).toBe(true) + expect(getDropdownTrigger()).toBeInTheDocument() }) }) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/dropdown/__tests__/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/dropdown/__tests__/index.spec.tsx index f7112c243b..d57e8340e9 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/dropdown/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/dropdown/__tests__/index.spec.tsx @@ -32,10 +32,10 @@ describe('Dropdown', () => { const { container } = render() - // Assert - Button should have RiMoreFill icon (rendered as svg) + // Assert - Button should have the more icon const button = screen.getByRole('button') - expect(button)!.toBeInTheDocument() - expect(container.querySelector('svg'))!.toBeInTheDocument() + expect(button).toBeInTheDocument() + expect(container.querySelector('.i-ri-more-fill')).toBeInTheDocument() }) it('should render separator after dropdown', () => { diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/dropdown/index.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/dropdown/index.tsx index 7178b45b34..c6a90824ab 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/dropdown/index.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/dropdown/index.tsx @@ -1,12 +1,11 @@ import { cn } from '@langgenius/dify-ui/cn' -import { RiMoreFill } from '@remixicon/react' import * as React from 'react' import { useCallback, useState } from 'react' import { - PortalToFollowElem, - PortalToFollowElemContent, - PortalToFollowElemTrigger, -} from '@/app/components/base/portal-to-follow-elem' + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, +} from '@/app/components/base/ui/dropdown-menu' import Menu from './menu' type DropdownProps = { @@ -22,26 +21,17 @@ const Dropdown = ({ }: DropdownProps) => { const [open, setOpen] = useState(false) - const handleTrigger = useCallback(() => { - setOpen(prev => !prev) - }, []) - const handleBreadCrumbClick = useCallback((index: number) => { onBreadcrumbClick(index) setOpen(false) }, [onBreadcrumbClick]) return ( - - + }> - - + + - + / - + ) } diff --git a/web/app/components/explore/item-operation/__tests__/index.spec.tsx b/web/app/components/explore/item-operation/__tests__/index.spec.tsx index f7f9b44a84..d54c644ab0 100644 --- a/web/app/components/explore/item-operation/__tests__/index.spec.tsx +++ b/web/app/components/explore/item-operation/__tests__/index.spec.tsx @@ -1,6 +1,81 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import * as React from 'react' import ItemOperation from '../index' +vi.mock('@/app/components/base/ui/dropdown-menu', () => { + const DropdownMenuContext = React.createContext<{ isOpen: boolean, setOpen: (open: boolean) => void } | null>(null) + + const useDropdownMenuContext = () => { + const context = React.use(DropdownMenuContext) + if (!context) + throw new Error('DropdownMenu components must be wrapped in DropdownMenu') + return context + } + + return { + DropdownMenu: ({ children, open, onOpenChange }: { children: React.ReactNode, open: boolean, onOpenChange?: (open: boolean) => void }) => ( + +
{children}
+
+ ), + DropdownMenuTrigger: ({ + children, + onClick, + ...props + }: React.ButtonHTMLAttributes) => { + const { isOpen, setOpen } = useDropdownMenuContext() + return ( + + ) + }, + DropdownMenuContent: ({ + children, + popupProps, + }: { + children: React.ReactNode + popupProps?: React.HTMLAttributes + }) => { + const { isOpen } = useDropdownMenuContext() + if (!isOpen) + return null + + return
{children}
+ }, + DropdownMenuItem: ({ + children, + onClick, + className, + }: { + children: React.ReactNode + onClick?: React.MouseEventHandler + className?: string + }) => { + const { setOpen } = useDropdownMenuContext() + return ( + + ) + }, + } +}) + describe('ItemOperation', () => { beforeEach(() => { vi.clearAllMocks() @@ -67,14 +142,27 @@ describe('ItemOperation', () => { expect(props.onDelete).toHaveBeenCalledTimes(1) }) + + it('should call onRenameConversation when clicking rename action', async () => { + const onRenameConversation = vi.fn() + renderComponent({ + isShowRenameConversation: true, + onRenameConversation, + }) + + fireEvent.click(screen.getByTestId('item-operation-trigger')) + fireEvent.click(await screen.findByText('explore.sidebar.action.rename')) + + expect(onRenameConversation).toHaveBeenCalledTimes(1) + }) }) describe('Edge Cases', () => { it('should close the menu when mouse leaves the panel and item is not hovering', async () => { renderComponent() fireEvent.click(screen.getByTestId('item-operation-trigger')) - const pinText = await screen.findByText('explore.sidebar.action.pin') - const menu = pinText.closest('div')?.parentElement as HTMLElement + await screen.findByText('explore.sidebar.action.pin') + const menu = screen.getByTestId('dropdown-content') fireEvent.mouseEnter(menu) fireEvent.mouseLeave(menu) @@ -83,5 +171,25 @@ describe('ItemOperation', () => { expect(screen.queryByText('explore.sidebar.action.pin')).not.toBeInTheDocument() }) }) + + it('should stop propagation when clicking inside the dropdown content', async () => { + const onParentClick = vi.fn() + + render( +
+ +
, + ) + + fireEvent.click(screen.getByTestId('item-operation-trigger')) + fireEvent.click(await screen.findByTestId('dropdown-content')) + + expect(onParentClick).not.toHaveBeenCalled() + }) }) }) diff --git a/web/app/components/explore/item-operation/index.tsx b/web/app/components/explore/item-operation/index.tsx index 94eed731fa..72bd00fa6e 100644 --- a/web/app/components/explore/item-operation/index.tsx +++ b/web/app/components/explore/item-operation/index.tsx @@ -7,10 +7,14 @@ import { } from '@remixicon/react' import { useBoolean } from 'ahooks' import * as React from 'react' -import { useEffect, useRef, useState } from 'react' +import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' - -import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/app/components/base/ui/dropdown-menu' import { Pin02 } from '../../base/icons/src/vender/line/general' import s from './style.module.css' @@ -35,61 +39,74 @@ const ItemOperation: FC = ({ isShowDelete, onDelete, }) => { - const { t } = useTranslation() + const { t } = useTranslation('explore') + const { t: tCommon } = useTranslation('common') const [open, setOpen] = useState(false) - const ref = useRef(null) const [isHovering, { setTrue: setIsHovering, setFalse: setNotHovering }] = useBoolean(false) useEffect(() => { if (!isItemHovering && !isHovering) setOpen(false) }, [isItemHovering, isHovering]) return ( - - setOpen(v => !v)} + { + e.stopPropagation() + }} > -
-
-
- {tCommon('operation.more')} + + e.stopPropagation(), + }} > -
{ e.stopPropagation() + togglePin() }} > -
- - {isPinned ? t('sidebar.action.unpin', { ns: 'explore' }) : t('sidebar.action.pin', { ns: 'explore' })} -
- {isShowRenameConversation && ( -
- - {t('sidebar.action.rename', { ns: 'explore' })} -
- )} - {isShowDelete && ( -
- - {t('sidebar.action.delete', { ns: 'explore' })} -
- )} -
-
-
+ + {isPinned ? t('sidebar.action.unpin') : t('sidebar.action.pin')} + + {isShowRenameConversation && ( + { + e.stopPropagation() + onRenameConversation?.() + }} + > + + {t('sidebar.action.rename')} + + )} + {isShowDelete && ( + { + e.stopPropagation() + onDelete() + }} + > + + {t('sidebar.action.delete')} + + )} + + ) } export default React.memo(ItemOperation) diff --git a/web/app/components/header/account-setting/data-source-page-new/__tests__/card.spec.tsx b/web/app/components/header/account-setting/data-source-page-new/__tests__/card.spec.tsx index fedb7d8a4e..bd349a3ad6 100644 --- a/web/app/components/header/account-setting/data-source-page-new/__tests__/card.spec.tsx +++ b/web/app/components/header/account-setting/data-source-page-new/__tests__/card.spec.tsx @@ -188,10 +188,12 @@ describe('Card Component', () => { fireEvent.click(screen.getByText(/operation.edit/)) // Assert - expect(mockPluginAuthActionReturn.handleEdit).toHaveBeenCalledWith('c1', { - apiKey: 'key1', - __name__: 'Credential 1', - __credential_id__: 'c1', + await waitFor(() => { + expect(mockPluginAuthActionReturn.handleEdit).toHaveBeenCalledWith('c1', { + apiKey: 'key1', + __name__: 'Credential 1', + __credential_id__: 'c1', + }) }) }) @@ -202,7 +204,9 @@ describe('Card Component', () => { fireEvent.click(screen.getByText(/operation.remove/)) // Assert - expect(mockPluginAuthActionReturn.openConfirm).toHaveBeenCalledWith('c1') + await waitFor(() => { + expect(mockPluginAuthActionReturn.openConfirm).toHaveBeenCalledWith('c1') + }) }) it('should handle "setDefault" action from Item component', async () => { @@ -212,7 +216,9 @@ describe('Card Component', () => { fireEvent.click(screen.getByText(/auth.setDefault/)) // Assert - expect(mockPluginAuthActionReturn.handleSetDefault).toHaveBeenCalledWith('c1') + await waitFor(() => { + expect(mockPluginAuthActionReturn.handleSetDefault).toHaveBeenCalledWith('c1') + }) }) it('should handle "rename" action from Item component', async () => { @@ -231,14 +237,16 @@ describe('Card Component', () => { fireEvent.click(screen.getByText(/operation.rename/)) // Now it should show an input - const input = screen.getByPlaceholderText(/placeholder.input/) + const input = await screen.findByPlaceholderText(/placeholder.input/) fireEvent.change(input, { target: { value: 'New Name' } }) fireEvent.click(screen.getByText(/operation.save/)) // Assert - expect(mockPluginAuthActionReturn.handleRename).toHaveBeenCalledWith({ - credential_id: 'c1', - name: 'New Name', + await waitFor(() => { + expect(mockPluginAuthActionReturn.handleRename).toHaveBeenCalledWith({ + credential_id: 'c1', + name: 'New Name', + }) }) }) diff --git a/web/app/components/header/account-setting/data-source-page-new/__tests__/item.spec.tsx b/web/app/components/header/account-setting/data-source-page-new/__tests__/item.spec.tsx index b88b3a8bb7..e1c5c3f4c7 100644 --- a/web/app/components/header/account-setting/data-source-page-new/__tests__/item.spec.tsx +++ b/web/app/components/header/account-setting/data-source-page-new/__tests__/item.spec.tsx @@ -1,5 +1,5 @@ import type { DataSourceCredential } from '../types' -import { fireEvent, render, screen } from '@testing-library/react' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { CredentialTypeEnum } from '@/app/components/plugins/plugin-auth/types' import Item from '../item' @@ -14,6 +14,9 @@ const triggerRename = async () => { fireEvent.click(dropdownTrigger) const renameOption = await screen.findByText('common.operation.rename') fireEvent.click(renameOption) + await waitFor(() => { + expect(screen.getByPlaceholderText('common.placeholder.input')).toBeInTheDocument() + }) } describe('Item Component', () => { diff --git a/web/app/components/header/account-setting/data-source-page-new/__tests__/operator.spec.tsx b/web/app/components/header/account-setting/data-source-page-new/__tests__/operator.spec.tsx index 0140626dd7..12042acfa1 100644 --- a/web/app/components/header/account-setting/data-source-page-new/__tests__/operator.spec.tsx +++ b/web/app/components/header/account-setting/data-source-page-new/__tests__/operator.spec.tsx @@ -1,5 +1,6 @@ import type { DataSourceCredential } from '../types' -import { fireEvent, render, screen } from '@testing-library/react' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import { CredentialTypeEnum } from '@/app/components/plugins/plugin-auth/types' import Operator from '../operator' @@ -9,10 +10,6 @@ import Operator from '../operator' */ // Helper to open dropdown -const openDropdown = () => { - fireEvent.click(screen.getByRole('button')) -} - describe('Operator Component', () => { const mockOnAction = vi.fn() const mockOnRename = vi.fn() @@ -37,7 +34,7 @@ describe('Operator Component', () => { // Act render() - openDropdown() + await userEvent.setup().click(screen.getByRole('button')) // Assert expect(await screen.findByText('plugin.auth.setDefault')).toBeInTheDocument() @@ -53,7 +50,7 @@ describe('Operator Component', () => { // Act render() - openDropdown() + await userEvent.setup().click(screen.getByRole('button')) // Assert expect(await screen.findByText('plugin.auth.setDefault')).toBeInTheDocument() @@ -71,11 +68,13 @@ describe('Operator Component', () => { render() // Act - openDropdown() + await userEvent.setup().click(screen.getByRole('button')) fireEvent.click(await screen.findByText('common.operation.rename')) // Assert - expect(mockOnRename).toHaveBeenCalledTimes(1) + await waitFor(() => { + expect(mockOnRename).toHaveBeenCalledTimes(1) + }) expect(mockOnAction).not.toHaveBeenCalled() }) @@ -85,7 +84,7 @@ describe('Operator Component', () => { render() // Act & Assert - openDropdown() + await userEvent.setup().click(screen.getByRole('button')) const renameBtn = await screen.findByText('common.operation.rename') expect(() => fireEvent.click(renameBtn)).not.toThrow() }) @@ -96,11 +95,13 @@ describe('Operator Component', () => { render() // Act - openDropdown() + await userEvent.setup().click(screen.getByRole('button')) fireEvent.click(await screen.findByText('plugin.auth.setDefault')) // Assert - expect(mockOnAction).toHaveBeenCalledWith('setDefault', credential) + await waitFor(() => { + expect(mockOnAction).toHaveBeenCalledWith('setDefault', credential) + }) }) it('should call onAction for "edit" action', async () => { @@ -109,11 +110,13 @@ describe('Operator Component', () => { render() // Act - openDropdown() + await userEvent.setup().click(screen.getByRole('button')) fireEvent.click(await screen.findByText('common.operation.edit')) // Assert - expect(mockOnAction).toHaveBeenCalledWith('edit', credential) + await waitFor(() => { + expect(mockOnAction).toHaveBeenCalledWith('edit', credential) + }) }) it('should call onAction for "change" action', async () => { @@ -122,11 +125,13 @@ describe('Operator Component', () => { render() // Act - openDropdown() + await userEvent.setup().click(screen.getByRole('button')) fireEvent.click(await screen.findByText('common.dataSource.notion.changeAuthorizedPages')) // Assert - expect(mockOnAction).toHaveBeenCalledWith('change', credential) + await waitFor(() => { + expect(mockOnAction).toHaveBeenCalledWith('change', credential) + }) }) it('should call onAction for "delete" action', async () => { @@ -135,11 +140,13 @@ describe('Operator Component', () => { render() // Act - openDropdown() + await userEvent.setup().click(screen.getByRole('button')) fireEvent.click(await screen.findByText('common.operation.remove')) // Assert - expect(mockOnAction).toHaveBeenCalledWith('delete', credential) + await waitFor(() => { + expect(mockOnAction).toHaveBeenCalledWith('delete', credential) + }) }) }) }) diff --git a/web/app/components/header/account-setting/data-source-page-new/operator.tsx b/web/app/components/header/account-setting/data-source-page-new/operator.tsx index c94f1bb192..437fa80139 100644 --- a/web/app/components/header/account-setting/data-source-page-new/operator.tsx +++ b/web/app/components/header/account-setting/data-source-page-new/operator.tsx @@ -1,21 +1,20 @@ import type { DataSourceCredential, } from './types' -import type { Item } from '@/app/components/base/dropdown' -import { - RiDeleteBinLine, - RiEditLine, - RiEqualizer2Line, - RiHome9Line, - RiStickyNoteAddLine, -} from '@remixicon/react' import { memo, useCallback, - useMemo, + useState, } from 'react' import { useTranslation } from 'react-i18next' -import Dropdown from '@/app/components/base/dropdown' +import ActionButton from '@/app/components/base/action-button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/app/components/base/ui/dropdown-menu' import { CredentialTypeEnum } from '@/app/components/plugins/plugin-auth/types' type OperatorProps = { @@ -29,106 +28,60 @@ const Operator = ({ onRename, }: OperatorProps) => { const { t } = useTranslation() + const [open, setOpen] = useState(false) const { type, } = credentialItem - const items = useMemo(() => { - const commonItems = [ - { - value: 'setDefault', - text: ( -
- -
{t('auth.setDefault', { ns: 'plugin' })}
-
- ), - }, - ...( - type === CredentialTypeEnum.OAUTH2 - ? [ - { - value: 'rename', - text: ( -
- -
{t('operation.rename', { ns: 'common' })}
-
- ), - }, - ] - : [] - ), - ...( - type === CredentialTypeEnum.API_KEY - ? [ - { - value: 'edit', - text: ( -
- -
{t('operation.edit', { ns: 'common' })}
-
- ), - }, - ] - : [] - ), - ] - if (type === CredentialTypeEnum.OAUTH2) { - const oAuthItems = [ - { - value: 'change', - text: ( -
- -
{t('dataSource.notion.changeAuthorizedPages', { ns: 'common' })}
-
- ), - }, - ] - commonItems.push(...oAuthItems) - } - return commonItems - }, [t, type]) - - const secondItems = useMemo(() => { - return [ - { - value: 'delete', - text: ( -
- -
- {t('operation.remove', { ns: 'common' })} -
-
- ), - }, - ] - }, []) - const handleSelect = useCallback((item: Item) => { - if (item.value === 'rename') { - onRename?.() - return - } - onAction( - item.value as string, - credentialItem, - ) - }, [onAction, credentialItem, onRename]) + const handleAction = useCallback((action: string) => { + setOpen(false) + queueMicrotask(() => { + if (action === 'rename') { + onRename?.() + return + } + onAction(action, credentialItem) + }) + }, [credentialItem, onAction, onRename]) return ( - + + }> + + + + + + handleAction('setDefault')}> + +
{t('auth.setDefault', { ns: 'plugin' })}
+
+ {type === CredentialTypeEnum.OAUTH2 && ( + handleAction('rename')}> + +
{t('operation.rename', { ns: 'common' })}
+
+ )} + {type === CredentialTypeEnum.API_KEY && ( + handleAction('edit')}> + +
{t('operation.edit', { ns: 'common' })}
+
+ )} + {type === CredentialTypeEnum.OAUTH2 && ( + handleAction('change')}> + +
{t('dataSource.notion.changeAuthorizedPages', { ns: 'common' })}
+
+ )} + + handleAction('delete')}> + +
+ {t('operation.remove', { ns: 'common' })} +
+
+
+
) } diff --git a/web/app/components/header/account-setting/members-page/operation/index.tsx b/web/app/components/header/account-setting/members-page/operation/index.tsx index 5fb3be0195..dab0c84e5b 100644 --- a/web/app/components/header/account-setting/members-page/operation/index.tsx +++ b/web/app/components/header/account-setting/members-page/operation/index.tsx @@ -1,10 +1,15 @@ 'use client' import type { Member } from '@/models/common' -import { CheckIcon, ChevronDownIcon } from '@heroicons/react/24/outline' import { cn } from '@langgenius/dify-ui/cn' import { memo, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' -import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/app/components/base/ui/dropdown-menu' import { toast } from '@/app/components/base/ui/toast' import { useProviderContext } from '@/context/provider-context' import { deleteMemberOrCancelInvitation, updateMemberRole } from '@/service/common' @@ -74,40 +79,50 @@ const Operation = ({ member, operatorRole, onOperate }: IOperationProps) => { } } return ( - - setOpen(prev => !prev)}> -
- {RoleMap[member.role] || RoleMap.normal} - -
-
- -
-
- {roleList.map(role => ( -
handleUpdateMemberRole(role)}> - {role === member.role - ? - :
} -
-
{t(roleI18nKeyMap[role].label, { ns: 'common' })}
-
{t(roleI18nKeyMap[role].tip, { ns: 'common' })}
-
-
- ))} -
-
-
-
+ + } + > + {RoleMap[member.role] || RoleMap.normal} + + + +
+ {roleList.map(role => ( + handleUpdateMemberRole(role)} + > + {role === member.role + ? + : }
-
{t('members.removeFromTeam', { ns: 'common' })}
-
{t('members.removeFromTeamTip', { ns: 'common' })}
+
{t(roleI18nKeyMap[role].label, { ns: 'common' })}
+
{t(roleI18nKeyMap[role].tip, { ns: 'common' })}
-
-
+ + ))}
- - + +
+ + +
+
{t('members.removeFromTeam', { ns: 'common' })}
+
{t('members.removeFromTeamTip', { ns: 'common' })}
+
+
+
+ + ) } export default memo(Operation) diff --git a/web/app/components/plugins/marketplace/sort-dropdown/__tests__/index.spec.tsx b/web/app/components/plugins/marketplace/sort-dropdown/__tests__/index.spec.tsx index 4d93726c4c..990bb321de 100644 --- a/web/app/components/plugins/marketplace/sort-dropdown/__tests__/index.spec.tsx +++ b/web/app/components/plugins/marketplace/sort-dropdown/__tests__/index.spec.tsx @@ -1,15 +1,12 @@ -import { fireEvent, render, screen, within } from '@testing-library/react' +import type { + MouseEventHandler, + ReactNode, +} from 'react' +import { render, screen, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { beforeEach, describe, expect, it, vi } from 'vitest' import SortDropdown from '../index' -// ================================ -// Mock external dependencies only -// ================================ - -// Mock i18n translation hook const mockTranslation = vi.fn((key: string, options?: { ns?: string }) => { - // Build full key with namespace prefix if provided const fullKey = options?.ns ? `${options.ns}.${key}` : key const translations: Record = { 'plugin.marketplace.sortBy': 'Sort by', @@ -27,7 +24,6 @@ vi.mock('#i18n', () => ({ }), })) -// Mock marketplace atoms with controllable values let mockSort: { sortBy: string, sortOrder: string } = { sortBy: 'install_count', sortOrder: 'DESC' } const mockHandleSortChange = vi.fn() @@ -35,664 +31,123 @@ vi.mock('../../atoms', () => ({ useMarketplaceSort: () => [mockSort, mockHandleSortChange], })) -// Mock portal component with controllable open state -let mockPortalOpenState = false +vi.mock('@/app/components/base/ui/dropdown-menu', async () => { + const React = await import('react') + const DropdownMenuContext = React.createContext<{ open: boolean, setOpen: (open: boolean) => void } | null>(null) -vi.mock('@/app/components/base/portal-to-follow-elem', () => ({ - PortalToFollowElem: ({ children, open, onOpenChange: _onOpenChange }: { - children: React.ReactNode - open: boolean - onOpenChange: (open: boolean) => void - }) => { - mockPortalOpenState = open - return ( -
- {children} -
- ) - }, - PortalToFollowElemTrigger: ({ children, onClick }: { - children: React.ReactNode - onClick: () => void - }) => ( -
- {children} -
- ), - PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) => { - // Match actual behavior: only render when portal is open - if (!mockPortalOpenState) - return null - return
{children}
- }, -})) + const useDropdownMenuContext = () => { + const context = React.use(DropdownMenuContext) + if (!context) + throw new Error('DropdownMenu components must be wrapped in DropdownMenu') + return context + } -// ================================ -// Test Factory Functions -// ================================ + return { + DropdownMenu: ({ children, open, onOpenChange }: { children: ReactNode, open: boolean, onOpenChange?: (open: boolean) => void }) => ( + +
+ {children} +
+
+ ), + DropdownMenuTrigger: ({ children, className }: { children: ReactNode, className?: string }) => { + const { open, setOpen } = useDropdownMenuContext() + return ( + + ) + }, + DropdownMenuContent: ({ children }: { children: ReactNode }) => { + const { open } = useDropdownMenuContext() + return open ?
{children}
: null + }, + DropdownMenuItem: ({ + children, + onClick, + className, + }: { + children: ReactNode + onClick?: MouseEventHandler + className?: string + }) => { + const { setOpen } = useDropdownMenuContext() + return ( + + ) + }, + } +}) -type SortOption = { - value: string - order: string - text: string -} - -const createSortOptions = (): SortOption[] => [ - { value: 'install_count', order: 'DESC', text: 'Most Popular' }, - { value: 'version_updated_at', order: 'DESC', text: 'Recently Updated' }, - { value: 'created_at', order: 'DESC', text: 'Newly Released' }, - { value: 'created_at', order: 'ASC', text: 'First Released' }, -] - -// ================================ -// SortDropdown Component Tests -// ================================ describe('SortDropdown', () => { beforeEach(() => { vi.clearAllMocks() mockSort = { sortBy: 'install_count', sortOrder: 'DESC' } - mockPortalOpenState = false }) - // ================================ - // Rendering Tests - // ================================ - describe('Rendering', () => { - it('should render without crashing', () => { - render() + it('renders the selected sort option in the trigger', () => { + render() - expect(screen.getByTestId('portal-wrapper')).toBeInTheDocument() - }) - - it('should render sort by label', () => { - render() - - expect(screen.getByText('Sort by')).toBeInTheDocument() - }) - - it('should render selected option text', () => { - render() - - expect(screen.getByText('Most Popular')).toBeInTheDocument() - }) - - it('should render arrow down icon', () => { - const { container } = render() - - const arrowIcon = container.querySelector('.h-4.w-4.text-text-tertiary') - expect(arrowIcon).toBeInTheDocument() - }) - - it('should render trigger element with correct styles', () => { - const { container } = render() - - const trigger = container.querySelector('.cursor-pointer') - expect(trigger).toBeInTheDocument() - expect(trigger).toHaveClass('h-8', 'rounded-lg', 'bg-state-base-hover-alt') - }) - - it('should not render dropdown content when closed', () => { - render() - - expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument() - }) + const trigger = screen.getByTestId('dropdown-trigger') + expect(within(trigger).getByText('Sort by')).toBeInTheDocument() + expect(within(trigger).getByText('Most Popular')).toBeInTheDocument() }) - // ================================ - // State Management Tests - // ================================ - describe('State Management', () => { - it('should initialize with closed state', () => { - render() + it('falls back to the default option when the current sort is invalid', () => { + mockSort = { sortBy: 'unknown', sortOrder: 'ASC' } - const wrapper = screen.getByTestId('portal-wrapper') - expect(wrapper).toHaveAttribute('data-open', 'false') - }) + render() - it('should display correct selected option for install_count DESC', () => { - mockSort = { sortBy: 'install_count', sortOrder: 'DESC' } - render() - - expect(screen.getByText('Most Popular')).toBeInTheDocument() - }) - - it('should display correct selected option for version_updated_at DESC', () => { - mockSort = { sortBy: 'version_updated_at', sortOrder: 'DESC' } - render() - - expect(screen.getByText('Recently Updated')).toBeInTheDocument() - }) - - it('should display correct selected option for created_at DESC', () => { - mockSort = { sortBy: 'created_at', sortOrder: 'DESC' } - render() - - expect(screen.getByText('Newly Released')).toBeInTheDocument() - }) - - it('should display correct selected option for created_at ASC', () => { - mockSort = { sortBy: 'created_at', sortOrder: 'ASC' } - render() - - expect(screen.getByText('First Released')).toBeInTheDocument() - }) - - it('should toggle open state when trigger clicked', () => { - render() - - const trigger = screen.getByTestId('portal-trigger') - fireEvent.click(trigger) - - // After click, portal content should be visible - expect(screen.getByTestId('portal-content')).toBeInTheDocument() - }) - - it('should close dropdown when trigger clicked again', () => { - render() - - const trigger = screen.getByTestId('portal-trigger') - - // Open - fireEvent.click(trigger) - expect(screen.getByTestId('portal-content')).toBeInTheDocument() - - // Close - fireEvent.click(trigger) - expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument() - }) + expect(screen.getByText('Most Popular')).toBeInTheDocument() }) - // ================================ - // User Interactions Tests - // ================================ - describe('User Interactions', () => { - it('should open dropdown on trigger click', () => { - render() + it('opens the menu and renders all sort options', async () => { + const user = userEvent.setup() + render() - const trigger = screen.getByTestId('portal-trigger') - fireEvent.click(trigger) + await user.click(screen.getByTestId('dropdown-trigger')) - expect(screen.getByTestId('portal-content')).toBeInTheDocument() - }) - - it('should render all sort options when open', () => { - render() - - // Open dropdown - fireEvent.click(screen.getByTestId('portal-trigger')) - - const content = screen.getByTestId('portal-content') - expect(within(content).getByText('Most Popular')).toBeInTheDocument() - expect(within(content).getByText('Recently Updated')).toBeInTheDocument() - expect(within(content).getByText('Newly Released')).toBeInTheDocument() - expect(within(content).getByText('First Released')).toBeInTheDocument() - }) - - it('should call handleSortChange when option clicked', () => { - render() - - // Open dropdown - fireEvent.click(screen.getByTestId('portal-trigger')) - - // Click on "Recently Updated" - const content = screen.getByTestId('portal-content') - fireEvent.click(within(content).getByText('Recently Updated')) - - expect(mockHandleSortChange).toHaveBeenCalledWith({ - sortBy: 'version_updated_at', - sortOrder: 'DESC', - }) - }) - - it('should call handleSortChange with correct params for Most Popular', () => { - mockSort = { sortBy: 'created_at', sortOrder: 'DESC' } - render() - - fireEvent.click(screen.getByTestId('portal-trigger')) - - const content = screen.getByTestId('portal-content') - fireEvent.click(within(content).getByText('Most Popular')) - - expect(mockHandleSortChange).toHaveBeenCalledWith({ - sortBy: 'install_count', - sortOrder: 'DESC', - }) - }) - - it('should call handleSortChange with correct params for Newly Released', () => { - render() - - fireEvent.click(screen.getByTestId('portal-trigger')) - - const content = screen.getByTestId('portal-content') - fireEvent.click(within(content).getByText('Newly Released')) - - expect(mockHandleSortChange).toHaveBeenCalledWith({ - sortBy: 'created_at', - sortOrder: 'DESC', - }) - }) - - it('should call handleSortChange with correct params for First Released', () => { - render() - - fireEvent.click(screen.getByTestId('portal-trigger')) - - const content = screen.getByTestId('portal-content') - fireEvent.click(within(content).getByText('First Released')) - - expect(mockHandleSortChange).toHaveBeenCalledWith({ - sortBy: 'created_at', - sortOrder: 'ASC', - }) - }) - - it('should allow selecting currently selected option', () => { - mockSort = { sortBy: 'install_count', sortOrder: 'DESC' } - render() - - fireEvent.click(screen.getByTestId('portal-trigger')) - - const content = screen.getByTestId('portal-content') - fireEvent.click(within(content).getByText('Most Popular')) - - expect(mockHandleSortChange).toHaveBeenCalledWith({ - sortBy: 'install_count', - sortOrder: 'DESC', - }) - }) - - it('should support userEvent for trigger click', async () => { - const user = userEvent.setup() - render() - - const trigger = screen.getByTestId('portal-trigger') - await user.click(trigger) - - expect(screen.getByTestId('portal-content')).toBeInTheDocument() - }) + const content = screen.getByTestId('dropdown-content') + expect(within(content).getByText('Most Popular')).toBeInTheDocument() + expect(within(content).getByText('Recently Updated')).toBeInTheDocument() + expect(within(content).getByText('Newly Released')).toBeInTheDocument() + expect(within(content).getByText('First Released')).toBeInTheDocument() }) - // ================================ - // Check Icon Tests - // ================================ - describe('Check Icon', () => { - it('should show check icon for selected option', () => { - mockSort = { sortBy: 'install_count', sortOrder: 'DESC' } - const { container } = render() + it('shows a check icon for the currently selected option', async () => { + const user = userEvent.setup() + const { container } = render() - // Open dropdown - fireEvent.click(screen.getByTestId('portal-trigger')) + await user.click(screen.getByTestId('dropdown-trigger')) - // Check icon should be present in the dropdown - const checkIcon = container.querySelector('.text-text-accent') - expect(checkIcon).toBeInTheDocument() - }) - - it('should show check icon only for matching sortBy AND sortOrder', () => { - mockSort = { sortBy: 'created_at', sortOrder: 'DESC' } - render() - - fireEvent.click(screen.getByTestId('portal-trigger')) - - const content = screen.getByTestId('portal-content') - const options = content.querySelectorAll('.cursor-pointer') - - // "Newly Released" (created_at DESC) should have check icon - // "First Released" (created_at ASC) should NOT have check icon - expect(options.length).toBe(4) - }) - - it('should not show check icon for different sortOrder with same sortBy', () => { - mockSort = { sortBy: 'created_at', sortOrder: 'DESC' } - const { container } = render() - - fireEvent.click(screen.getByTestId('portal-trigger')) - - // Only one check icon should be visible (for Newly Released, not First Released) - const checkIcons = container.querySelectorAll('.text-text-accent') - expect(checkIcons.length).toBe(1) - }) + expect(container.querySelector('.i-ri-check-line')).toBeInTheDocument() }) - // ================================ - // Dropdown Options Structure Tests - // ================================ - describe('Dropdown Options Structure', () => { - const sortOptions = createSortOptions() + it('updates the sort and closes the menu when an option is selected', async () => { + const user = userEvent.setup() + render() - it('should render 4 sort options', () => { - render() + await user.click(screen.getByTestId('dropdown-trigger')) + await user.click(screen.getByText('Recently Updated')) - fireEvent.click(screen.getByTestId('portal-trigger')) - - const content = screen.getByTestId('portal-content') - const options = content.querySelectorAll('.cursor-pointer') - expect(options.length).toBe(4) + expect(mockHandleSortChange).toHaveBeenCalledWith({ + sortBy: 'version_updated_at', + sortOrder: 'DESC', }) - - it.each(sortOptions)('should render option: $text', ({ text }) => { - render() - - fireEvent.click(screen.getByTestId('portal-trigger')) - - const content = screen.getByTestId('portal-content') - expect(within(content).getByText(text)).toBeInTheDocument() - }) - - it('should render options with unique keys', () => { - render() - - fireEvent.click(screen.getByTestId('portal-trigger')) - - const content = screen.getByTestId('portal-content') - const options = content.querySelectorAll('.cursor-pointer') - - // All options should be rendered (no key conflicts) - expect(options.length).toBe(4) - }) - - it('should render dropdown container with correct styles', () => { - render() - - fireEvent.click(screen.getByTestId('portal-trigger')) - - const content = screen.getByTestId('portal-content') - const container = content.firstChild as HTMLElement - expect(container).toHaveClass('rounded-xl', 'shadow-lg') - }) - - it('should render option items with hover styles', () => { - render() - - fireEvent.click(screen.getByTestId('portal-trigger')) - - const content = screen.getByTestId('portal-content') - const option = content.querySelector('.cursor-pointer') - expect(option).toHaveClass('hover:bg-components-panel-on-panel-item-bg-hover') - }) - }) - - // ================================ - // Edge Cases Tests - // ================================ - describe('Edge Cases', () => { - // The component falls back to the first option (Most Popular) when sort values are invalid - - it('should fallback to default option when sortBy is unknown', () => { - mockSort = { sortBy: 'unknown_field', sortOrder: 'DESC' } - - render() - - // Should fallback to first option "Most Popular" - expect(screen.getByText('Most Popular')).toBeInTheDocument() - }) - - it('should fallback to default option when sortBy is empty', () => { - mockSort = { sortBy: '', sortOrder: 'DESC' } - - render() - - expect(screen.getByText('Most Popular')).toBeInTheDocument() - }) - - it('should fallback to default option when sortOrder is unknown', () => { - mockSort = { sortBy: 'install_count', sortOrder: 'UNKNOWN' } - - render() - - expect(screen.getByText('Most Popular')).toBeInTheDocument() - }) - - it('should render correctly when handleSortChange is a no-op', () => { - mockHandleSortChange.mockImplementation(() => {}) - render() - - fireEvent.click(screen.getByTestId('portal-trigger')) - - const content = screen.getByTestId('portal-content') - fireEvent.click(within(content).getByText('Recently Updated')) - - expect(mockHandleSortChange).toHaveBeenCalled() - }) - - it('should handle rapid toggle clicks', () => { - render() - - const trigger = screen.getByTestId('portal-trigger') - - // Rapid clicks - fireEvent.click(trigger) - fireEvent.click(trigger) - fireEvent.click(trigger) - - // Final state should be open (odd number of clicks) - expect(screen.getByTestId('portal-content')).toBeInTheDocument() - }) - - it('should handle multiple option selections', () => { - render() - - fireEvent.click(screen.getByTestId('portal-trigger')) - - const content = screen.getByTestId('portal-content') - - // Click multiple options - fireEvent.click(within(content).getByText('Recently Updated')) - fireEvent.click(within(content).getByText('Newly Released')) - fireEvent.click(within(content).getByText('First Released')) - - expect(mockHandleSortChange).toHaveBeenCalledTimes(3) - }) - }) - - // ================================ - // Context Integration Tests - // ================================ - describe('Context Integration', () => { - it('should read sort value from context', () => { - mockSort = { sortBy: 'version_updated_at', sortOrder: 'DESC' } - render() - - expect(screen.getByText('Recently Updated')).toBeInTheDocument() - }) - - it('should call context handleSortChange on selection', () => { - render() - - fireEvent.click(screen.getByTestId('portal-trigger')) - - const content = screen.getByTestId('portal-content') - fireEvent.click(within(content).getByText('First Released')) - - expect(mockHandleSortChange).toHaveBeenCalledWith({ - sortBy: 'created_at', - sortOrder: 'ASC', - }) - }) - - it('should update display when context sort changes', () => { - const { rerender } = render() - - expect(screen.getByText('Most Popular')).toBeInTheDocument() - - // Simulate context change - mockSort = { sortBy: 'created_at', sortOrder: 'ASC' } - rerender() - - expect(screen.getByText('First Released')).toBeInTheDocument() - }) - - it('should use selector pattern correctly', () => { - render() - - // Component should have called useMarketplaceContext with selector functions - expect(screen.getByTestId('portal-wrapper')).toBeInTheDocument() - }) - }) - - // ================================ - // Accessibility Tests - // ================================ - describe('Accessibility', () => { - it('should have cursor pointer on trigger', () => { - const { container } = render() - - const trigger = container.querySelector('.cursor-pointer') - expect(trigger).toBeInTheDocument() - }) - - it('should have cursor pointer on options', () => { - render() - - fireEvent.click(screen.getByTestId('portal-trigger')) - - const content = screen.getByTestId('portal-content') - const options = content.querySelectorAll('.cursor-pointer') - expect(options.length).toBeGreaterThan(0) - }) - - it('should have visible focus indicators via hover styles', () => { - render() - - fireEvent.click(screen.getByTestId('portal-trigger')) - - const content = screen.getByTestId('portal-content') - const option = content.querySelector('.hover\\:bg-components-panel-on-panel-item-bg-hover') - expect(option).toBeInTheDocument() - }) - }) - - // ================================ - // Translation Tests - // ================================ - describe('Translations', () => { - it('should call translation for sortBy label', () => { - render() - - expect(mockTranslation).toHaveBeenCalledWith('marketplace.sortBy', { ns: 'plugin' }) - }) - - it('should call translation for all sort options', () => { - render() - - expect(mockTranslation).toHaveBeenCalledWith('marketplace.sortOption.mostPopular', { ns: 'plugin' }) - expect(mockTranslation).toHaveBeenCalledWith('marketplace.sortOption.recentlyUpdated', { ns: 'plugin' }) - expect(mockTranslation).toHaveBeenCalledWith('marketplace.sortOption.newlyReleased', { ns: 'plugin' }) - expect(mockTranslation).toHaveBeenCalledWith('marketplace.sortOption.firstReleased', { ns: 'plugin' }) - }) - }) - - // ================================ - // Portal Component Integration Tests - // ================================ - describe('Portal Component Integration', () => { - it('should pass open state to PortalToFollowElem', () => { - render() - - const wrapper = screen.getByTestId('portal-wrapper') - expect(wrapper).toHaveAttribute('data-open', 'false') - - fireEvent.click(screen.getByTestId('portal-trigger')) - - expect(wrapper).toHaveAttribute('data-open', 'true') - }) - - it('should render trigger content inside PortalToFollowElemTrigger', () => { - render() - - const trigger = screen.getByTestId('portal-trigger') - expect(within(trigger).getByText('Sort by')).toBeInTheDocument() - expect(within(trigger).getByText('Most Popular')).toBeInTheDocument() - }) - - it('should render options inside PortalToFollowElemContent', () => { - render() - - fireEvent.click(screen.getByTestId('portal-trigger')) - - const content = screen.getByTestId('portal-content') - expect(within(content).getByText('Most Popular')).toBeInTheDocument() - }) - }) - - // ================================ - // Visual Style Tests - // ================================ - describe('Visual Styles', () => { - it('should apply correct trigger container styles', () => { - const { container } = render() - - const triggerDiv = container.querySelector('.flex.h-8.cursor-pointer.items-center.rounded-lg') - expect(triggerDiv).toBeInTheDocument() - }) - - it('should apply secondary text color to sort by label', () => { - const { container } = render() - - const label = container.querySelector('.text-text-secondary') - expect(label).toBeInTheDocument() - expect(label?.textContent).toBe('Sort by') - }) - - it('should apply primary text color to selected option', () => { - const { container } = render() - - const selected = container.querySelector('.text-text-primary.system-sm-medium') - expect(selected).toBeInTheDocument() - }) - - it('should apply tertiary text color to arrow icon', () => { - const { container } = render() - - const arrow = container.querySelector('.text-text-tertiary') - expect(arrow).toBeInTheDocument() - }) - - it('should apply accent text color to check icon when option selected', () => { - mockSort = { sortBy: 'install_count', sortOrder: 'DESC' } - const { container } = render() - - fireEvent.click(screen.getByTestId('portal-trigger')) - - const checkIcon = container.querySelector('.text-text-accent') - expect(checkIcon).toBeInTheDocument() - }) - - it('should apply blur-sm backdrop to dropdown container', () => { - render() - - fireEvent.click(screen.getByTestId('portal-trigger')) - - const content = screen.getByTestId('portal-content') - const container = content.querySelector('.backdrop-blur-xs') - expect(container).toBeInTheDocument() - }) - }) - - // ================================ - // All Sort Options Click Tests - // ================================ - describe('All Sort Options Click Handlers', () => { - const testCases = [ - { text: 'Most Popular', sortBy: 'install_count', sortOrder: 'DESC' }, - { text: 'Recently Updated', sortBy: 'version_updated_at', sortOrder: 'DESC' }, - { text: 'Newly Released', sortBy: 'created_at', sortOrder: 'DESC' }, - { text: 'First Released', sortBy: 'created_at', sortOrder: 'ASC' }, - ] - - it.each(testCases)( - 'should call handleSortChange with { sortBy: "$sortBy", sortOrder: "$sortOrder" } when clicking "$text"', - ({ text, sortBy, sortOrder }) => { - render() - - fireEvent.click(screen.getByTestId('portal-trigger')) - - const content = screen.getByTestId('portal-content') - fireEvent.click(within(content).getByText(text)) - - expect(mockHandleSortChange).toHaveBeenCalledWith({ sortBy, sortOrder }) - }, - ) + expect(screen.queryByTestId('dropdown-content')).not.toBeInTheDocument() }) }) diff --git a/web/app/components/plugins/marketplace/sort-dropdown/index.tsx b/web/app/components/plugins/marketplace/sort-dropdown/index.tsx index 99f5650a71..a47143de02 100644 --- a/web/app/components/plugins/marketplace/sort-dropdown/index.tsx +++ b/web/app/components/plugins/marketplace/sort-dropdown/index.tsx @@ -1,15 +1,12 @@ 'use client' import { useTranslation } from '#i18n' -import { - RiArrowDownSLine, - RiCheckLine, -} from '@remixicon/react' import { useState } from 'react' import { - PortalToFollowElem, - PortalToFollowElemContent, - PortalToFollowElemTrigger, -} from '@/app/components/base/portal-to-follow-elem' + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/app/components/base/ui/dropdown-menu' import { useMarketplaceSort } from '../atoms' const SortDropdown = () => { @@ -38,50 +35,44 @@ const SortDropdown = () => { ] const [sort, handleSortChange] = useMarketplaceSort() const [open, setOpen] = useState(false) - const selectedOption = options.find(option => option.value === sort.sortBy && option.order === sort.sortOrder) ?? options[0] + const selectedOption = options.find(option => option.value === sort.sortBy && option.order === sort.sortOrder) ?? options[0]! return ( - - setOpen(v => !v)}> -
- - {t('marketplace.sortBy', { ns: 'plugin' })} - - - {selectedOption!.text} - - -
-
- -
- { - options.map(option => ( -
handleSortChange({ sortBy: option.value, sortOrder: option.order })} - > - {option.text} - { - sort.sortBy === option.value && sort.sortOrder === option.order && ( - - ) - } -
- )) - } -
-
-
+ + + {t('marketplace.sortBy', { ns: 'plugin' })} + + + {selectedOption.text} + + + + + {options.map(option => ( + { + handleSortChange({ sortBy: option.value, sortOrder: option.order }) + setOpen(false) + }} + > + {option.text} + {sort.sortBy === option.value && sort.sortOrder === option.order && ( + + )} + + ))} + + ) } diff --git a/web/app/components/plugins/plugin-page/__tests__/install-plugin-dropdown.spec.tsx b/web/app/components/plugins/plugin-page/__tests__/install-plugin-dropdown.spec.tsx index 28aec206f1..1f249b16c6 100644 --- a/web/app/components/plugins/plugin-page/__tests__/install-plugin-dropdown.spec.tsx +++ b/web/app/components/plugins/plugin-page/__tests__/install-plugin-dropdown.spec.tsx @@ -36,34 +36,85 @@ vi.mock('@/app/components/base/icons/src/vender/solid/mediaAndDevices', () => ({ })) vi.mock('@/app/components/base/ui/button', () => ({ - Button: ({ children }: { children: React.ReactNode }) => {children}, + Button: ({ children, onClick, className, ...props }: React.ButtonHTMLAttributes) => ( + + ), })) -vi.mock('@/app/components/base/portal-to-follow-elem', async () => { +vi.mock('@/app/components/base/ui/dropdown-menu', async () => { const React = await import('react') + const DropdownMenuContext = React.createContext<{ isOpen: boolean, setOpen: (open: boolean) => void } | null>(null) + + const useDropdownMenuContext = () => { + const context = React.use(DropdownMenuContext) + if (!context) + throw new Error('DropdownMenu components must be wrapped in DropdownMenu') + return context + } + return { - PortalToFollowElem: ({ + DropdownMenu: ({ open, + onOpenChange, children, }: { open: boolean + onOpenChange?: (open: boolean) => void children: React.ReactNode }) => { portalOpen = open - return
{children}
+ return ( + +
{children}
+
+ ) }, - PortalToFollowElemTrigger: ({ + DropdownMenuTrigger: ({ children, onClick, + render, }: { children: React.ReactNode - onClick: () => void - }) => , - PortalToFollowElemContent: ({ + onClick?: React.MouseEventHandler + render?: React.ReactElement + }) => { + const { isOpen, setOpen } = useDropdownMenuContext() + const handleClick = (e: React.MouseEvent) => { + onClick?.(e) + setOpen(!isOpen) + } + + if (render) + return React.cloneElement(render, { 'data-testid': 'dropdown-trigger', 'onClick': handleClick } as Record, children) + + return + }, + DropdownMenuContent: ({ children, }: { children: React.ReactNode }) => portalOpen ?
{children}
: null, + DropdownMenuItem: ({ + children, + onClick, + }: { + children: React.ReactNode + onClick?: React.MouseEventHandler + }) => { + const { setOpen } = useDropdownMenuContext() + return ( + + ) + }, } }) @@ -131,13 +182,13 @@ describe('InstallPluginDropdown', () => { expect(onSwitchToMarketplaceTab).toHaveBeenCalledTimes(1) }) - it('opens the github installer when github is selected', () => { + it('opens the github installer when github is selected', async () => { render() fireEvent.click(screen.getByTestId('dropdown-trigger')) fireEvent.click(screen.getByText('plugin.source.github')) - expect(screen.getByTestId('github-modal')).toBeInTheDocument() + expect(await screen.findByTestId('github-modal')).toBeInTheDocument() }) it('opens the local package installer when a file is selected', () => { @@ -153,4 +204,40 @@ describe('InstallPluginDropdown', () => { expect(screen.getByTestId('local-modal')).toBeInTheDocument() expect(screen.getByText('plugin.difypkg')).toBeInTheDocument() }) + + it('triggers the hidden file input when local is selected from the menu', () => { + const clickSpy = vi.spyOn(HTMLInputElement.prototype, 'click') + + render() + + fireEvent.click(screen.getByTestId('dropdown-trigger')) + fireEvent.click(screen.getByText('plugin.source.local')) + + expect(clickSpy).toHaveBeenCalledTimes(1) + clickSpy.mockRestore() + }) + + it('closes the github installer when the modal requests close', async () => { + render() + + fireEvent.click(screen.getByTestId('dropdown-trigger')) + fireEvent.click(screen.getByText('plugin.source.github')) + fireEvent.click(await screen.findByTestId('close-github-modal')) + + expect(screen.queryByTestId('github-modal')).not.toBeInTheDocument() + }) + + it('closes the local package installer when the modal requests close', () => { + const { container } = render() + + fireEvent.click(screen.getByTestId('dropdown-trigger')) + fireEvent.change(container.querySelector('input[type="file"]')!, { + target: { + files: [new File(['content'], 'plugin.difypkg')], + }, + }) + fireEvent.click(screen.getByTestId('close-local-modal')) + + expect(screen.queryByTestId('local-modal')).not.toBeInTheDocument() + }) }) diff --git a/web/app/components/plugins/plugin-page/install-plugin-dropdown.tsx b/web/app/components/plugins/plugin-page/install-plugin-dropdown.tsx index 4bfe495b93..9b98d16410 100644 --- a/web/app/components/plugins/plugin-page/install-plugin-dropdown.tsx +++ b/web/app/components/plugins/plugin-page/install-plugin-dropdown.tsx @@ -8,12 +8,13 @@ import { useTranslation } from 'react-i18next' import { FileZip } from '@/app/components/base/icons/src/vender/solid/files' import { Github } from '@/app/components/base/icons/src/vender/solid/general' import { MagicBox } from '@/app/components/base/icons/src/vender/solid/mediaAndDevices' -import { - PortalToFollowElem, - PortalToFollowElemContent, - PortalToFollowElemTrigger, -} from '@/app/components/base/portal-to-follow-elem' import { Button } from '@/app/components/base/ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/app/components/base/ui/dropdown-menu' import InstallFromGitHub from '@/app/components/plugins/install-plugin/install-from-github' import InstallFromLocalPackage from '@/app/components/plugins/install-plugin/install-from-local-package' import { SUPPORT_INSTALL_LOCAL_FILE_EXTENSIONS } from '@/config' @@ -77,61 +78,66 @@ const InstallPluginDropdown = ({ } }, [plugin_installation_permission, enable_marketplace, t]) + const handleInstallMethodSelect = (action: string) => { + if (action === 'local') { + fileInputRef.current?.click() + return + } + + if (action === 'marketplace') { + onSwitchToMarketplaceTab() + return + } + + queueMicrotask(() => { + setSelectedAction(action) + }) + } + return ( - +
- setIsMenuOpen(v => !v)}> - - - -
- - {t('installFrom', { ns: 'plugin' })} - - -
- {installMethods.map(({ icon: Icon, text, action }) => ( -
{ - if (action === 'local') { - fileInputRef.current?.click() - } - else if (action === 'marketplace') { - onSwitchToMarketplaceTab() - setIsMenuOpen(false) - } - else { - setSelectedAction(action) - setIsMenuOpen(false) - } - }} - > - - {text} -
- ))} -
-
-
+ + + + + {t('installFrom', { ns: 'plugin' })} + + + {installMethods.map(({ icon: Icon, text, action }) => ( + handleInstallMethodSelect(action)} + > +
+ + {text} +
+
+ ))} +
{selectedAction === 'github' && ( (
handleUninstall(item.id)}>{item.name} 卸载
))} */} -
+ ) } diff --git a/web/app/components/plugins/plugin-page/plugin-tasks/__tests__/index.spec.tsx b/web/app/components/plugins/plugin-page/plugin-tasks/__tests__/index.spec.tsx index 15b4429de8..c87673b750 100644 --- a/web/app/components/plugins/plugin-page/plugin-tasks/__tests__/index.spec.tsx +++ b/web/app/components/plugins/plugin-page/plugin-tasks/__tests__/index.spec.tsx @@ -685,6 +685,26 @@ describe('PluginTasks Component', () => { }) }) + it('should close the menu after clearing the last non-running plugins', async () => { + setupMocks([ + createMockPlugin({ status: TaskStatus.success, plugin_unique_identifier: 'success-1' }), + ]) + + render() + + fireEvent.click(document.getElementById('plugin-task-trigger')!) + + await waitFor(() => { + expect(document.querySelector('.w-\\[360px\\]')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByRole('button', { name: /task\.clearAll/i })) + + await waitFor(() => { + expect(document.querySelector('.w-\\[360px\\]')).not.toBeInTheDocument() + }) + }) + it('should clear only error plugins when onClearErrors is called', async () => { const { mockMutateAsync } = setupMocks([ createMockPlugin({ status: TaskStatus.failed, plugin_unique_identifier: 'error-1' }), @@ -797,6 +817,30 @@ describe('PluginTasks Component', () => { expect(document.querySelector('.w-\\[360px\\]'))!.toBeInTheDocument() }) + + it('should open for installing-with-success state', () => { + setupMocks([ + createMockPlugin({ status: TaskStatus.running, plugin_unique_identifier: 'running-1' }), + createMockPlugin({ status: TaskStatus.success, plugin_unique_identifier: 'success-1' }), + ]) + + render() + fireEvent.click(document.getElementById('plugin-task-trigger')!) + + expect(document.querySelector('.w-\\[360px\\]')).toBeInTheDocument() + }) + + it('should open for installing-with-error state', () => { + setupMocks([ + createMockPlugin({ status: TaskStatus.running, plugin_unique_identifier: 'running-1' }), + createMockPlugin({ status: TaskStatus.failed, plugin_unique_identifier: 'failed-1' }), + ]) + + render() + fireEvent.click(document.getElementById('plugin-task-trigger')!) + + expect(document.querySelector('.w-\\[360px\\]')).toBeInTheDocument() + }) }) }) diff --git a/web/app/components/plugins/plugin-page/plugin-tasks/components/__tests__/plugin-item.spec.tsx b/web/app/components/plugins/plugin-page/plugin-tasks/components/__tests__/plugin-item.spec.tsx index 772e246e22..ea8257af2a 100644 --- a/web/app/components/plugins/plugin-page/plugin-tasks/components/__tests__/plugin-item.spec.tsx +++ b/web/app/components/plugins/plugin-page/plugin-tasks/components/__tests__/plugin-item.spec.tsx @@ -202,6 +202,7 @@ describe('PluginItem', () => { fireEvent.click(clearButton) expect(handleClear).toHaveBeenCalledTimes(1) + expect(clearButton).toHaveClass('invisible', 'flex', 'group-hover/item:visible') }) it('should not render clear button when onClear is not provided', () => { diff --git a/web/app/components/plugins/plugin-page/plugin-tasks/components/error-plugin-item.tsx b/web/app/components/plugins/plugin-page/plugin-tasks/components/error-plugin-item.tsx index ef857cb6c6..0129c65ee2 100644 --- a/web/app/components/plugins/plugin-page/plugin-tasks/components/error-plugin-item.tsx +++ b/web/app/components/plugins/plugin-page/plugin-tasks/components/error-plugin-item.tsx @@ -108,7 +108,7 @@ const ErrorPluginItem: FC = ({ plugin, getIconUrl, languag )} statusText={( - + {plugin.message || errorMsg} )} diff --git a/web/app/components/plugins/plugin-page/plugin-tasks/components/plugin-item.tsx b/web/app/components/plugins/plugin-page/plugin-tasks/components/plugin-item.tsx index f627047b24..2fdb5696ea 100644 --- a/web/app/components/plugins/plugin-page/plugin-tasks/components/plugin-item.tsx +++ b/web/app/components/plugins/plugin-page/plugin-tasks/components/plugin-item.tsx @@ -39,7 +39,7 @@ const PluginItem: FC = ({
{plugin.labels[language]}
-
+
{statusText}
{action} @@ -47,7 +47,7 @@ const PluginItem: FC = ({ {onClear && (
-
+
{plugins.map(plugin => ( = ({ {t('task.clearAll', { ns: 'plugin' })}
-
+
{errorPlugins.map(plugin => ( { handleClearErrorPlugin, } = usePluginTaskStatus() const { getIconUrl } = useGetIcon() + const canOpenMenu = isFailed || isInstalling || isInstallingWithSuccess || isInstallingWithError || isSuccess // Generate tooltip text based on status const tip = useMemo(() => { @@ -85,27 +86,20 @@ const PluginTasks = () => { [clearPluginsAndClose], ) - const handleTriggerClick = useCallback(() => { - if (isFailed || isInstalling || isInstallingWithSuccess || isInstallingWithError || isSuccess) - setOpen(v => !v) - }, [isFailed, isInstalling, isInstallingWithSuccess, isInstallingWithError, isSuccess]) - // Hide when no plugin tasks if (totalPluginsLength === 0) return null return (
- - + } + disabled={!canOpenMenu} + > { totalPluginsLength={totalPluginsLength} onClick={() => {}} /> - - + + { onClearErrors={handleClearErrors} onClearSingle={handleClearSingle} /> - - + +
) } diff --git a/web/app/components/share/text-generation/__tests__/menu-dropdown.spec.tsx b/web/app/components/share/text-generation/__tests__/menu-dropdown.spec.tsx index 7ccd788cb0..edad871855 100644 --- a/web/app/components/share/text-generation/__tests__/menu-dropdown.spec.tsx +++ b/web/app/components/share/text-generation/__tests__/menu-dropdown.spec.tsx @@ -3,6 +3,27 @@ import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-libra import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import MenuDropdown from '../menu-dropdown' +vi.mock('../info-modal', () => ({ + default: ({ + isShow, + onClose, + data, + }: { + isShow: boolean + onClose: () => void + data?: SiteInfo + }) => { + if (!isShow) + return null + return ( +
+ {data?.title} + +
+ ) + }, +})) + const mockReplace = vi.fn() const mockPathname = '/test-path' vi.mock('@/next/navigation', () => ({ @@ -191,6 +212,25 @@ describe('MenuDropdown', () => { expect(screen.getByText('Test App')).toBeInTheDocument() }) }) + + it('should close InfoModal when the close handler runs', async () => { + render() + + fireEvent.click(screen.getByRole('button')) + await waitFor(() => { + expect(screen.getByText('common.userProfile.about')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByText('common.userProfile.about')) + await waitFor(() => { + expect(screen.getByTestId('info-modal')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByText('Close Info')) + await waitFor(() => { + expect(screen.queryByTestId('info-modal')).not.toBeInTheDocument() + }) + }) }) describe('forceClose prop', () => { diff --git a/web/app/components/share/text-generation/menu-dropdown.tsx b/web/app/components/share/text-generation/menu-dropdown.tsx index bc8323676c..d50a0d77de 100644 --- a/web/app/components/share/text-generation/menu-dropdown.tsx +++ b/web/app/components/share/text-generation/menu-dropdown.tsx @@ -1,26 +1,25 @@ 'use client' -import type { Placement } from '@floating-ui/react' import type { FC } from 'react' +import type { Placement } from '@/app/components/base/ui/placement' import type { SiteInfo } from '@/models/share' import { cn } from '@langgenius/dify-ui/cn' -import { - RiEqualizer2Line, -} from '@remixicon/react' import * as React from 'react' -import { useCallback, useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import ActionButton from '@/app/components/base/action-button' -import { - PortalToFollowElem, - PortalToFollowElemContent, - PortalToFollowElemTrigger, -} from '@/app/components/base/portal-to-follow-elem' import ThemeSwitcher from '@/app/components/base/theme-switcher' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLinkItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/app/components/base/ui/dropdown-menu' import { useWebAppStore } from '@/context/web-app-context' import { AccessMode } from '@/models/access-control' import { usePathname, useRouter } from '@/next/navigation' import { webAppLogout } from '@/service/webapp-auth' -import Divider from '../../base/divider' import InfoModal from './info-modal' type Props = { @@ -40,24 +39,22 @@ const MenuDropdown: FC = ({ const router = useRouter() const pathname = usePathname() const { t } = useTranslation() - const [open, doSetOpen] = useState(false) - const openRef = useRef(open) - const setOpen = useCallback((v: boolean) => { - doSetOpen(v) - openRef.current = v - }, [doSetOpen]) - - const handleTrigger = useCallback(() => { - setOpen(!openRef.current) - }, [setOpen]) + const [open, setOpen] = useState(false) const shareCode = useWebAppStore(s => s.shareCode) const handleLogout = useCallback(async () => { + setOpen(false) await webAppLogout(shareCode!) router.replace(`/webapp-signin?redirect_url=${pathname}`) - }, [router, pathname, webAppLogout, shareCode]) + }, [pathname, router, setOpen, shareCode]) const [show, setShow] = useState(false) + const handleOpenInfoModal = useCallback(() => { + setOpen(false) + queueMicrotask(() => { + setShow(true) + }) + }, []) useEffect(() => { if (forceClose) @@ -66,60 +63,56 @@ const MenuDropdown: FC = ({ return ( <> - - -
- - - -
-
- -
-
-
-
{t('theme.theme', { ns: 'common' })}
- -
+ } + aria-label={t('operation.more', { ns: 'common' })} + > + + + + + +
+
+
{t('theme.theme', { ns: 'common' })}
+
- -
- {data?.privacy_policy && ( - - {t('chat.privacyPolicyMiddle', { ns: 'share' })} - - )} -
{ - handleTrigger() - setShow(true) - }} - className="cursor-pointer rounded-lg px-3 py-1.5 system-md-regular text-text-secondary hover:bg-state-base-hover" - > - {t('userProfile.about', { ns: 'common' })} -
-
- {!(hideLogout || webAppAccessMode === AccessMode.EXTERNAL_MEMBERS || webAppAccessMode === AccessMode.PUBLIC) && ( -
-
- {t('userProfile.logout', { ns: 'common' })} -
-
- )}
- - + + {data?.privacy_policy && ( + + {t('chat.privacyPolicyMiddle', { ns: 'share' })} + + )} + + {t('userProfile.about', { ns: 'common' })} + + {!(hideLogout || webAppAccessMode === AccessMode.EXTERNAL_MEMBERS || webAppAccessMode === AccessMode.PUBLIC) && ( + + {t('userProfile.logout', { ns: 'common' })} + + )} +
+ {show && ( { + const React = await import('react') + const DropdownMenuContext = React.createContext<{ isOpen: boolean, setOpen: (open: boolean) => void } | null>(null) + + const useDropdownMenuContext = () => { + const context = React.use(DropdownMenuContext) + if (!context) + throw new Error('DropdownMenu components must be wrapped in DropdownMenu') + return context + } + + return { + DropdownMenu: ({ children, open, onOpenChange }: { children: React.ReactNode, open: boolean, onOpenChange?: (open: boolean) => void }) => ( + +
{children}
+
+ ), + DropdownMenuTrigger: ({ + children, + render, + onClick, + }: { + children: React.ReactNode + render?: React.ReactElement + onClick?: React.MouseEventHandler + }) => { + const { isOpen, setOpen } = useDropdownMenuContext() + const handleClick = (e: React.MouseEvent) => { + onClick?.(e) + setOpen(!isOpen) + } + + if (render) + return React.cloneElement(render, { 'data-testid': 'dropdown-trigger', 'onClick': handleClick } as Record, children) + + return + }, + DropdownMenuContent: ({ + children, + className, + popupClassName, + }: { + children: React.ReactNode + className?: string + popupClassName?: string + }) => { + const { isOpen } = useDropdownMenuContext() + return isOpen ?
{children}
: null + }, + DropdownMenuItem: ({ + children, + onClick, + className, + }: { + children: React.ReactNode + onClick?: React.MouseEventHandler + className?: string + }) => { + const { setOpen } = useDropdownMenuContext() + return ( + + ) + }, + } +}) + describe('OperationDropdown', () => { const defaultProps = { onEdit: vi.fn(), @@ -16,7 +92,7 @@ describe('OperationDropdown', () => { it('should render trigger button with more icon', () => { render() - const button = document.querySelector('button') + const button = screen.getByTestId('dropdown-trigger') expect(button).toBeInTheDocument() const svg = button?.querySelector('svg') expect(svg).toBeInTheDocument() @@ -39,37 +115,27 @@ describe('OperationDropdown', () => { it('should open dropdown when trigger is clicked', async () => { render() - const trigger = document.querySelector('button') - if (trigger) { - fireEvent.click(trigger) + fireEvent.click(screen.getByTestId('dropdown-trigger')) - // Dropdown content should be rendered - expect(screen.getByText('tools.mcp.operation.edit')).toBeInTheDocument() - expect(screen.getByText('tools.mcp.operation.remove')).toBeInTheDocument() - } + expect(screen.getByText('tools.mcp.operation.edit')).toBeInTheDocument() + expect(screen.getByText('tools.mcp.operation.remove')).toBeInTheDocument() }) it('should call onOpenChange when opened', () => { const onOpenChange = vi.fn() render() - const trigger = document.querySelector('button') - if (trigger) { - fireEvent.click(trigger) - expect(onOpenChange).toHaveBeenCalledWith(true) - } + fireEvent.click(screen.getByTestId('dropdown-trigger')) + expect(onOpenChange).toHaveBeenCalledWith(true) }) it('should close dropdown when trigger is clicked again', async () => { const onOpenChange = vi.fn() render() - const trigger = document.querySelector('button') - if (trigger) { - fireEvent.click(trigger) - fireEvent.click(trigger) - expect(onOpenChange).toHaveBeenLastCalledWith(false) - } + fireEvent.click(screen.getByTestId('dropdown-trigger')) + fireEvent.click(screen.getByTestId('dropdown-trigger')) + expect(onOpenChange).toHaveBeenLastCalledWith(false) }) }) @@ -78,62 +144,38 @@ describe('OperationDropdown', () => { const onEdit = vi.fn() render() - const trigger = document.querySelector('button') - if (trigger) { - fireEvent.click(trigger) - - const editOption = screen.getByText('tools.mcp.operation.edit') - fireEvent.click(editOption) - - expect(onEdit).toHaveBeenCalledTimes(1) - } + fireEvent.click(screen.getByTestId('dropdown-trigger')) + fireEvent.click(screen.getByText('tools.mcp.operation.edit')) + expect(onEdit).toHaveBeenCalledTimes(1) }) it('should call onRemove when remove option is clicked', () => { const onRemove = vi.fn() render() - const trigger = document.querySelector('button') - if (trigger) { - fireEvent.click(trigger) - - const removeOption = screen.getByText('tools.mcp.operation.remove') - fireEvent.click(removeOption) - - expect(onRemove).toHaveBeenCalledTimes(1) - } + fireEvent.click(screen.getByTestId('dropdown-trigger')) + fireEvent.click(screen.getByText('tools.mcp.operation.remove')) + expect(onRemove).toHaveBeenCalledTimes(1) }) it('should close dropdown after edit is clicked', () => { const onOpenChange = vi.fn() render() - const trigger = document.querySelector('button') - if (trigger) { - fireEvent.click(trigger) - onOpenChange.mockClear() - - const editOption = screen.getByText('tools.mcp.operation.edit') - fireEvent.click(editOption) - - expect(onOpenChange).toHaveBeenCalledWith(false) - } + fireEvent.click(screen.getByTestId('dropdown-trigger')) + onOpenChange.mockClear() + fireEvent.click(screen.getByText('tools.mcp.operation.edit')) + expect(onOpenChange).toHaveBeenCalledWith(false) }) it('should close dropdown after remove is clicked', () => { const onOpenChange = vi.fn() render() - const trigger = document.querySelector('button') - if (trigger) { - fireEvent.click(trigger) - onOpenChange.mockClear() - - const removeOption = screen.getByText('tools.mcp.operation.remove') - fireEvent.click(removeOption) - - expect(onOpenChange).toHaveBeenCalledWith(false) - } + fireEvent.click(screen.getByTestId('dropdown-trigger')) + onOpenChange.mockClear() + fireEvent.click(screen.getByText('tools.mcp.operation.remove')) + expect(onOpenChange).toHaveBeenCalledWith(false) }) }) @@ -141,39 +183,25 @@ describe('OperationDropdown', () => { it('should have correct dropdown width', () => { render() - const trigger = document.querySelector('button') - if (trigger) { - fireEvent.click(trigger) - - const dropdown = document.querySelector('.w-\\[160px\\]') - expect(dropdown).toBeInTheDocument() - } + fireEvent.click(screen.getByTestId('dropdown-trigger')) + const dropdown = document.querySelector('.w-\\[160px\\]') + expect(dropdown).toBeInTheDocument() }) - it('should have rounded-xl on dropdown', () => { + it('should render dropdown content through the shared popup shell', () => { render() - const trigger = document.querySelector('button') - if (trigger) { - fireEvent.click(trigger) - - const dropdown = document.querySelector('[class*="rounded-xl"][class*="border"]') - expect(dropdown).toBeInTheDocument() - } + fireEvent.click(screen.getByTestId('dropdown-trigger')) + expect(screen.getByTestId('dropdown-content')).toBeInTheDocument() }) - it('should show destructive hover style on remove option', () => { + it('should apply destructive highlighted styles on remove option', () => { render() - const trigger = document.querySelector('button') - if (trigger) { - fireEvent.click(trigger) - - // The text is in a div, and the hover style is on the parent div with group class - const removeOptionText = screen.getByText('tools.mcp.operation.remove') - const removeOptionContainer = removeOptionText.closest('.group') - expect(removeOptionContainer).toHaveClass('hover:bg-state-destructive-hover') - } + fireEvent.click(screen.getByTestId('dropdown-trigger')) + const removeOptionText = screen.getByText('tools.mcp.operation.remove') + const removeOptionContainer = removeOptionText.closest('button') + expect(removeOptionContainer).toHaveClass('data-highlighted:bg-state-destructive-hover') }) }) diff --git a/web/app/components/tools/mcp/detail/operation-dropdown.tsx b/web/app/components/tools/mcp/detail/operation-dropdown.tsx index 4f5468aebc..9a7ee67051 100644 --- a/web/app/components/tools/mcp/detail/operation-dropdown.tsx +++ b/web/app/components/tools/mcp/detail/operation-dropdown.tsx @@ -7,14 +7,15 @@ import { RiMoreFill, } from '@remixicon/react' import * as React from 'react' -import { useCallback, useRef, useState } from 'react' +import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import ActionButton from '@/app/components/base/action-button' import { - PortalToFollowElem, - PortalToFollowElemContent, - PortalToFollowElemTrigger, -} from '@/app/components/base/portal-to-follow-elem' + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/app/components/base/ui/dropdown-menu' type Props = { inCard?: boolean @@ -30,60 +31,37 @@ const OperationDropdown: FC = ({ onRemove, }) => { const { t } = useTranslation() - const [open, doSetOpen] = useState(false) - const openRef = useRef(open) - const setOpen = useCallback((v: boolean) => { - doSetOpen(v) - openRef.current = v - onOpenChange?.(v) - }, [doSetOpen]) - - const handleTrigger = useCallback(() => { - setOpen(!openRef.current) - }, [setOpen]) + const [open, setOpen] = useState(false) + const handleOpenChange = useCallback((nextOpen: boolean) => { + setOpen(nextOpen) + onOpenChange?.(nextOpen) + }, [onOpenChange]) return ( - - -
- - - -
-
- -
-
{ - onEdit() - handleTrigger() - }} - > - -
{t('mcp.operation.edit', { ns: 'tools' })}
-
-
{ - onRemove() - handleTrigger() - }} - > - -
{t('mcp.operation.remove', { ns: 'tools' })}
-
-
-
-
+ + } + > + + + + + +
{t('mcp.operation.edit', { ns: 'tools' })}
+
+ + +
{t('mcp.operation.remove', { ns: 'tools' })}
+
+
+
) } export default React.memo(OperationDropdown) diff --git a/web/app/components/workflow/block-selector/market-place-plugin/__tests__/action.spec.tsx b/web/app/components/workflow/block-selector/market-place-plugin/__tests__/action.spec.tsx new file mode 100644 index 0000000000..1d845dd5fc --- /dev/null +++ b/web/app/components/workflow/block-selector/market-place-plugin/__tests__/action.spec.tsx @@ -0,0 +1,124 @@ +import type { ComponentProps } from 'react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { useDownloadPlugin } from '@/service/use-plugins' +import OperationDropdown from '../action' + +const mockDownloadBlob = vi.fn() +const mockRemoveQueries = vi.fn() + +vi.mock('next-themes', () => ({ + useTheme: () => ({ + theme: 'light', + }), +})) + +vi.mock('@/service/use-plugins', () => ({ + useDownloadPlugin: vi.fn(), +})) + +vi.mock('@/utils/download', () => ({ + downloadBlob: (...args: unknown[]) => mockDownloadBlob(...args), +})) + +vi.mock('@/utils/var', () => ({ + getMarketplaceUrl: (path: string) => `https://marketplace.example${path}`, +})) + +const createQueryClient = () => new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, +}) + +const renderComponent = (props?: Partial>) => { + const queryClient = createQueryClient() + vi.spyOn(queryClient, 'removeQueries').mockImplementation(((...args) => { + return mockRemoveQueries(...args) + }) as typeof queryClient.removeQueries) + + return render( + + + , + ) +} + +describe('OperationDropdown', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(useDownloadPlugin).mockImplementation((_, enabled) => ({ + data: enabled ? null : null, + isLoading: false, + }) as unknown as ReturnType) + }) + + it('should render download and view details actions when opened', async () => { + renderComponent({ open: true }) + + expect(screen.getByText('common.operation.download')).toBeInTheDocument() + expect(screen.getByText('common.operation.viewDetails')).toBeInTheDocument() + }) + + it('should request a download when download is clicked', async () => { + const onOpenChange = vi.fn() + renderComponent({ open: true, onOpenChange }) + + await userEvent.setup().click(screen.getByText('common.operation.download')) + + expect(onOpenChange).toHaveBeenCalledWith(false) + expect(mockRemoveQueries).toHaveBeenCalled() + }) + + it('should skip download when already loading', async () => { + vi.mocked(useDownloadPlugin).mockReturnValue({ + data: null, + isLoading: true, + } as unknown as ReturnType) + + renderComponent({ open: true }) + + await userEvent.setup().click(screen.getByText('common.operation.download')) + + expect(mockRemoveQueries).not.toHaveBeenCalled() + }) + + it('should download the blob when the hook returns data', async () => { + vi.mocked(useDownloadPlugin).mockImplementation((_, enabled) => ({ + data: enabled ? new Blob(['plugin zip'], { type: 'application/zip' }) : null, + isLoading: false, + }) as unknown as ReturnType) + + renderComponent({ open: true }) + + await userEvent.setup().click(screen.getByText('common.operation.download')) + + await waitFor(() => { + expect(mockDownloadBlob).toHaveBeenCalledWith({ + data: expect.any(Blob), + fileName: 'langgenius-test-plugin_1.0.0.zip', + }) + }) + expect(mockRemoveQueries).toHaveBeenCalled() + }) + + it('should link to the marketplace detail page', () => { + renderComponent({ open: true }) + + expect(screen.getByRole('menuitem', { name: 'common.operation.viewDetails' })).toHaveAttribute( + 'href', + 'https://marketplace.example/plugins/langgenius/test-plugin', + ) + }) +}) diff --git a/web/app/components/workflow/block-selector/market-place-plugin/action.tsx b/web/app/components/workflow/block-selector/market-place-plugin/action.tsx index 4ae623ffc1..a058e8c051 100644 --- a/web/app/components/workflow/block-selector/market-place-plugin/action.tsx +++ b/web/app/components/workflow/block-selector/market-place-plugin/action.tsx @@ -1,19 +1,19 @@ 'use client' import type { FC } from 'react' import { cn } from '@langgenius/dify-ui/cn' -import { RiMoreFill } from '@remixicon/react' import { useQueryClient } from '@tanstack/react-query' import { useTheme } from 'next-themes' import * as React from 'react' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import ActionButton from '@/app/components/base/action-button' -// import { Button } from '@/app/components/base/ui/button' import { - PortalToFollowElem, - PortalToFollowElemContent, - PortalToFollowElemTrigger, -} from '@/app/components/base/portal-to-follow-elem' + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLinkItem, + DropdownMenuTrigger, +} from '@/app/components/base/ui/dropdown-menu' import { useDownloadPlugin } from '@/service/use-plugins' import { downloadBlob } from '@/utils/download' import { getMarketplaceUrl } from '@/utils/var' @@ -36,16 +36,10 @@ const OperationDropdown: FC = ({ const { t } = useTranslation() const { theme } = useTheme() const queryClient = useQueryClient() - const openRef = useRef(open) - const setOpen = useCallback((v: boolean) => { - onOpenChange(v) - openRef.current = v + const setOpen = useCallback((value: boolean) => { + onOpenChange(value) }, [onOpenChange]) - const handleTrigger = useCallback(() => { - setOpen(!openRef.current) - }, [setOpen]) - const [needDownload, setNeedDownload] = useState(false) const downloadInfo = useMemo(() => ({ organization: author, @@ -56,12 +50,13 @@ const OperationDropdown: FC = ({ const handleDownload = useCallback(() => { if (isLoading) return + setOpen(false) queryClient.removeQueries({ queryKey: ['plugins', 'downloadPlugin', downloadInfo], exact: true, }) setNeedDownload(true) - }, [downloadInfo, isLoading, queryClient]) + }, [downloadInfo, isLoading, queryClient, setOpen]) useEffect(() => { if (!needDownload || !blob) @@ -75,27 +70,33 @@ const OperationDropdown: FC = ({ }) }, [author, blob, downloadInfo, name, needDownload, queryClient, version]) return ( - - + }> - + - - -
-
{t('operation.download', { ns: 'common' })}
- {t('operation.viewDetails', { ns: 'common' })} -
-
-
+ + + + {t('operation.download', { ns: 'common' })} + + + {t('operation.viewDetails', { ns: 'common' })} + + + ) } export default React.memo(OperationDropdown) diff --git a/web/app/components/workflow/header/__tests__/test-run-menu-helpers.spec.tsx b/web/app/components/workflow/header/__tests__/test-run-menu-helpers.spec.tsx index cce4c070a1..7df9cd091f 100644 --- a/web/app/components/workflow/header/__tests__/test-run-menu-helpers.spec.tsx +++ b/web/app/components/workflow/header/__tests__/test-run-menu-helpers.spec.tsx @@ -1,7 +1,7 @@ +import type * as React from 'react' import type { TriggerOption } from '../test-run-menu' import { fireEvent, render, renderHook, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import * as React from 'react' import { TriggerType } from '../test-run-menu' import { getNormalizedShortcutKey, @@ -10,6 +10,33 @@ import { useShortcutMenu, } from '../test-run-menu-helpers' +vi.mock('@/app/components/base/ui/dropdown-menu', async () => { + const React = await import('react') + const DropdownMenuContext = React.createContext<{ open: boolean, setOpen: (open: boolean) => void } | null>(null) + + const useDropdownMenuContext = () => { + const context = React.use(DropdownMenuContext) + if (!context) + throw new Error('DropdownMenu components must be wrapped in DropdownMenu') + return context + } + + return { + DropdownMenu: ({ children, open, onOpenChange }: { children: React.ReactNode, open: boolean, onOpenChange?: (open: boolean) => void }) => ( + +
{children}
+
+ ), + DropdownMenuContent: ({ children }: { children: React.ReactNode }) => { + const { open } = useDropdownMenuContext() + return open ?
{children}
: null + }, + DropdownMenuItem: ({ children, onClick, className }: { children: React.ReactNode, onClick?: React.MouseEventHandler, className?: string }) => ( + + ), + } +}) + vi.mock('../shortcuts-name', () => ({ default: ({ keys }: { keys: string[] }) => {keys.join('+')}, })) diff --git a/web/app/components/workflow/header/__tests__/test-run-menu.spec.tsx b/web/app/components/workflow/header/__tests__/test-run-menu.spec.tsx index 2e3384b61e..1d462bfc9c 100644 --- a/web/app/components/workflow/header/__tests__/test-run-menu.spec.tsx +++ b/web/app/components/workflow/header/__tests__/test-run-menu.spec.tsx @@ -5,25 +5,62 @@ import { act } from 'react' import * as React from 'react' import TestRunMenu, { TriggerType } from '../test-run-menu' -vi.mock('@/app/components/base/portal-to-follow-elem', () => ({ - PortalToFollowElem: ({ - children, - }: { - children: React.ReactNode - }) =>
{children}
, - PortalToFollowElemTrigger: ({ - children, - onClick, - }: { - children: React.ReactNode - onClick?: () => void - }) =>
{children}
, - PortalToFollowElemContent: ({ - children, - }: { - children: React.ReactNode - }) =>
{children}
, -})) +vi.mock('@/app/components/base/ui/dropdown-menu', async () => { + const React = await import('react') + const DropdownMenuContext = React.createContext<{ open: boolean, setOpen: (open: boolean) => void } | null>(null) + + const useDropdownMenuContext = () => { + const context = React.use(DropdownMenuContext) + if (!context) + throw new Error('DropdownMenu components must be wrapped in DropdownMenu') + return context + } + + return { + DropdownMenu: ({ children, open, onOpenChange }: { children: React.ReactNode, open: boolean, onOpenChange?: (open: boolean) => void }) => ( + +
{children}
+
+ ), + DropdownMenuTrigger: ({ + children, + render, + }: { + children: React.ReactNode + render?: React.ReactElement + }) => { + const { open, setOpen } = useDropdownMenuContext() + + if (render) + return React.cloneElement(render, { onClick: () => setOpen(!open) } as Record, children) + + return + }, + DropdownMenuContent: ({ children }: { children: React.ReactNode }) => { + const { open } = useDropdownMenuContext() + return open ?
{children}
: null + }, + DropdownMenuGroup: ({ children }: { children: React.ReactNode }) =>
{children}
, + DropdownMenuLabel: ({ children, className }: { children: React.ReactNode, className?: string }) =>
{children}
, + DropdownMenuGroupLabel: ({ children, className }: { children: React.ReactNode, className?: string }) =>
{children}
, + DropdownMenuSeparator: ({ className }: { className?: string }) =>
, + DropdownMenuItem: ({ children, onClick, className }: { children: React.ReactNode, onClick?: React.MouseEventHandler, className?: string }) => { + const { setOpen } = useDropdownMenuContext() + return ( + + ) + }, + } +}) vi.mock('../shortcuts-name', () => ({ default: ({ keys }: { keys: string[] }) => {keys.join('+')}, @@ -95,10 +132,11 @@ describe('TestRunMenu', () => { act(() => { fireEvent.click(screen.getByRole('button', { name: 'Toggle via ref' })) }) + expect(screen.getByText('~')).toBeInTheDocument() + fireEvent.keyDown(window, { key: '0' }) expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ id: 'run-all' })) - expect(screen.getByText('~')).toBeInTheDocument() }) it('should ignore disabled options in the rendered menu', async () => { diff --git a/web/app/components/workflow/header/test-run-menu-helpers.tsx b/web/app/components/workflow/header/test-run-menu-helpers.tsx index 1c34761df6..4a25cd87a6 100644 --- a/web/app/components/workflow/header/test-run-menu-helpers.tsx +++ b/web/app/components/workflow/header/test-run-menu-helpers.tsx @@ -6,6 +6,7 @@ import { isValidElement, useEffect, } from 'react' +import { DropdownMenuItem } from '@/app/components/base/ui/dropdown-menu' import ShortcutsName from '../shortcuts-name' export type ShortcutMapping = { @@ -27,9 +28,8 @@ export const OptionRow = ({ onSelect: (option: TriggerOption) => void }) => { return ( -
onSelect(option)} >
@@ -41,7 +41,7 @@ export const OptionRow = ({ {shortcutKey && ( )} -
+ ) } diff --git a/web/app/components/workflow/header/test-run-menu.tsx b/web/app/components/workflow/header/test-run-menu.tsx index 1d496e4332..6540875e6b 100644 --- a/web/app/components/workflow/header/test-run-menu.tsx +++ b/web/app/components/workflow/header/test-run-menu.tsx @@ -1,7 +1,7 @@ import type { ShortcutMapping } from './test-run-menu-helpers' import { forwardRef, useCallback, useImperativeHandle, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' -import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem' +import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from '@/app/components/base/ui/dropdown-menu' import { OptionRow, SingleOptionTrigger, useShortcutMenu } from './test-run-menu-helpers' export enum TriggerType { @@ -127,7 +127,7 @@ const TestRunMenu = forwardRef(({ }), [hasSingleEnabledOption, runSoleOption]) const renderOption = (option: TriggerOption) => { - return + return } const { hasUserInput, hasTriggers, hasRunAll } = useMemo(() => getMenuVisibility(options), [options]) @@ -141,27 +141,28 @@ const TestRunMenu = forwardRef(({ } return ( - - setOpen(!open)}> -
- {children} -
-
- -
-
+ }> + {children} + + + + {t('common.chooseStartNodeToRun', { ns: 'workflow' })} -
+
{hasUserInput && renderOption(options.userInput!)} {(hasTriggers || hasRunAll) && hasUserInput && ( -
+ )} {hasRunAll && renderOption(options.runAll!)} @@ -170,9 +171,9 @@ const TestRunMenu = forwardRef(({ .filter(trigger => trigger.enabled !== false) .map(trigger => renderOption(trigger))}
-
- - + + + ) }) diff --git a/web/app/components/workflow/nodes/_base/components/next-step/__tests__/operator.spec.tsx b/web/app/components/workflow/nodes/_base/components/next-step/__tests__/operator.spec.tsx new file mode 100644 index 0000000000..9afa29642d --- /dev/null +++ b/web/app/components/workflow/nodes/_base/components/next-step/__tests__/operator.spec.tsx @@ -0,0 +1,152 @@ +import type { + ReactNode, +} from 'react' +import type { CommonNodeType } from '@/app/components/workflow/types' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { useState } from 'react' +import { + useAvailableBlocks, + useNodesInteractions, +} from '@/app/components/workflow/hooks' +import { BlockEnum } from '@/app/components/workflow/types' +import Operator from '../operator' + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, options?: { ns?: string }) => options?.ns ? `${options.ns}.${key}` : key, + }), +})) + +vi.mock('@/app/components/base/ui/dropdown-menu', async () => { + const React = await import('react') + const DropdownMenuContext = React.createContext<{ open: boolean, setOpen: (open: boolean) => void } | null>(null) + + const useDropdownMenuContext = () => { + const context = React.use(DropdownMenuContext) + if (!context) + throw new Error('DropdownMenu components must be wrapped in DropdownMenu') + return context + } + + return { + DropdownMenu: ({ children, open, onOpenChange }: { children: ReactNode, open: boolean, onOpenChange?: (open: boolean) => void }) => ( + +
{children}
+
+ ), + DropdownMenuTrigger: ({ children, render }: { children: ReactNode, render?: ReactNode }) => { + const { open, setOpen } = useDropdownMenuContext() + if (render) + return
setOpen(!open)}>{children}
+ + return + }, + DropdownMenuContent: ({ children }: { children: ReactNode }) => { + const { open } = useDropdownMenuContext() + return open ?
{children}
: null + }, + } +}) + +vi.mock('@/app/components/base/ui/button', () => ({ + Button: ({ children, className }: { children: ReactNode, className?: string }) => ( + + ), +})) + +vi.mock('@/app/components/workflow/block-selector', () => ({ + default: ({ trigger, onSelect }: { trigger: ((open: boolean) => ReactNode) | ReactNode, onSelect: (type: BlockEnum) => void }) => ( +
+ {typeof trigger === 'function' ? trigger(false) : trigger} + +
+ ), +})) + +vi.mock('@/app/components/workflow/hooks', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useAvailableBlocks: vi.fn(), + useNodesInteractions: vi.fn(), + } +}) + +const mockUseAvailableBlocks = vi.mocked(useAvailableBlocks) +const mockUseNodesInteractions = vi.mocked(useNodesInteractions) + +const mockHandleNodeChange = vi.fn() +const mockHandleNodeDelete = vi.fn() +const mockHandleNodeDisconnect = vi.fn() + +const defaultNodeData = { + type: BlockEnum.Code, + title: 'Code Node', +} as CommonNodeType + +const TestHarness = () => { + const [open, setOpen] = useState(false) + return ( + + ) +} + +describe('NextStep operator', () => { + beforeEach(() => { + vi.clearAllMocks() + mockUseAvailableBlocks.mockReturnValue({ + availablePrevBlocks: [BlockEnum.HttpRequest], + availableNextBlocks: [BlockEnum.HttpRequest], + getAvailableBlocks: vi.fn(), + } as ReturnType) + mockUseNodesInteractions.mockReturnValue({ + handleNodeChange: mockHandleNodeChange, + handleNodeDelete: mockHandleNodeDelete, + handleNodeDisconnect: mockHandleNodeDisconnect, + } as unknown as ReturnType) + }) + + it('opens the menu and keeps the change action available', async () => { + const user = userEvent.setup() + render() + + await user.click(screen.getAllByRole('button')[0]!) + + expect(screen.getByText('workflow.panel.change')).toBeInTheDocument() + expect(screen.getByText('workflow.common.disconnect')).toBeInTheDocument() + expect(screen.getByText('common.operation.delete')).toBeInTheDocument() + }) + + it('changes the next-step block through the nested selector trigger', async () => { + const user = userEvent.setup() + render() + + await user.click(screen.getAllByRole('button')[0]!) + await user.click(screen.getByText('select-http')) + + expect(mockHandleNodeChange).toHaveBeenCalledWith('node-1', BlockEnum.HttpRequest, 'source', undefined) + }) + + it('disconnects and deletes the next step from the menu', async () => { + const user = userEvent.setup() + render() + + await user.click(screen.getAllByRole('button')[0]!) + await user.click(screen.getByText('workflow.common.disconnect')) + expect(mockHandleNodeDisconnect).toHaveBeenCalledWith('node-1') + expect(screen.queryByText('workflow.common.disconnect')).not.toBeInTheDocument() + + await user.click(screen.getAllByRole('button')[0]!) + await user.click(screen.getByText('common.operation.delete')) + expect(mockHandleNodeDelete).toHaveBeenCalledWith('node-1') + }) +}) diff --git a/web/app/components/workflow/nodes/_base/components/next-step/operator.tsx b/web/app/components/workflow/nodes/_base/components/next-step/operator.tsx index bc979eed60..c0a4f0b537 100644 --- a/web/app/components/workflow/nodes/_base/components/next-step/operator.tsx +++ b/web/app/components/workflow/nodes/_base/components/next-step/operator.tsx @@ -2,18 +2,17 @@ import type { CommonNodeType, OnSelectBlock, } from '@/app/components/workflow/types' -import { RiMoreFill } from '@remixicon/react' import { intersection } from 'es-toolkit/array' import { useCallback, } from 'react' import { useTranslation } from 'react-i18next' -import { - PortalToFollowElem, - PortalToFollowElemContent, - PortalToFollowElemTrigger, -} from '@/app/components/base/portal-to-follow-elem' import { Button } from '@/app/components/base/ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, +} from '@/app/components/base/ui/dropdown-menu' import BlockSelector from '@/app/components/workflow/block-selector' import { useAvailableBlocks, @@ -86,18 +85,21 @@ const Operator = ({ } = useNodesInteractions() return ( - - onOpenChange(!open)}> + }> - - + +
handleNodeDisconnect(nodeId)} + onClick={() => { + onOpenChange(false) + handleNodeDisconnect(nodeId) + }} > {t('common.disconnect', { ns: 'workflow' })}
@@ -115,14 +120,17 @@ const Operator = ({
handleNodeDelete(nodeId)} + onClick={() => { + onOpenChange(false) + handleNodeDelete(nodeId) + }} > {t('operation.delete', { ns: 'common' })}
- - + + ) } diff --git a/web/app/components/workflow/nodes/_base/components/panel-operator/__tests__/index.spec.tsx b/web/app/components/workflow/nodes/_base/components/panel-operator/__tests__/index.spec.tsx index 183e28c5f0..6dab0f33a5 100644 --- a/web/app/components/workflow/nodes/_base/components/panel-operator/__tests__/index.spec.tsx +++ b/web/app/components/workflow/nodes/_base/components/panel-operator/__tests__/index.spec.tsx @@ -70,7 +70,11 @@ const createQueryResult = (data: T): UseQueryResult => ({ promise: Promise.resolve(data), } as UseQueryResult) -const renderComponent = (showHelpLink: boolean = true, onOpenChange?: (open: boolean) => void) => +const renderComponent = ( + showHelpLink: boolean = true, + onOpenChange?: (open: boolean) => void, + offset?: { mainAxis: number, crossAxis: number } | number, +) => renderWorkflowFlowComponent( , @@ -158,5 +163,15 @@ describe('PanelOperator', () => { expect(screen.queryByText('workflow.panel.helpLink')).not.toBeInTheDocument() expect(screen.getByText('Node description')).toBeInTheDocument() }) + + it('should still open the popup when using a numeric offset and no open-change callback', async () => { + const user = userEvent.setup() + const { container } = renderComponent(true, undefined, 0) + + await user.click(container.querySelector('.panel-operator-trigger') as HTMLElement) + + expect(screen.getByText('workflow.panel.runThisStep')).toBeInTheDocument() + expect(screen.getByText('Node description')).toBeInTheDocument() + }) }) }) diff --git a/web/app/components/workflow/nodes/_base/components/panel-operator/index.tsx b/web/app/components/workflow/nodes/_base/components/panel-operator/index.tsx index 173a084dcf..2109365d75 100644 --- a/web/app/components/workflow/nodes/_base/components/panel-operator/index.tsx +++ b/web/app/components/workflow/nodes/_base/components/panel-operator/index.tsx @@ -1,23 +1,22 @@ import type { OffsetOptions } from '@floating-ui/react' import type { Node } from '@/app/components/workflow/types' -import { RiMoreFill } from '@remixicon/react' import { memo, useCallback, useState, } from 'react' import { - PortalToFollowElem, - PortalToFollowElemContent, - PortalToFollowElemTrigger, -} from '@/app/components/base/portal-to-follow-elem' + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, +} from '@/app/components/base/ui/dropdown-menu' import PanelOperatorPopup from './panel-operator-popup' type PanelOperatorProps = { id: string data: Node['data'] triggerClassName?: string - offset?: OffsetOptions + offset?: OffsetOptions | number onOpenChange?: (open: boolean) => void inNode?: boolean showHelpLink?: boolean @@ -34,6 +33,14 @@ const PanelOperator = ({ showHelpLink = true, }: PanelOperatorProps) => { const [open, setOpen] = useState(false) + const sideOffset = typeof offset === 'number' + ? offset + : typeof offset === 'object' && offset && 'mainAxis' in offset && typeof offset.mainAxis === 'number' + ? offset.mainAxis + : 4 + const alignOffset = typeof offset === 'object' && offset && 'crossAxis' in offset && typeof offset.crossAxis === 'number' + ? offset.crossAxis + : 0 const handleOpenChange = useCallback((newOpen: boolean) => { setOpen(newOpen) @@ -43,13 +50,11 @@ const PanelOperator = ({ }, [onOpenChange]) return ( - - handleOpenChange(!open)}> + }>
- +
-
- + + setOpen(false)} + onClosePopup={() => handleOpenChange(false)} showHelpLink={showHelpLink} /> - -
+ + ) } diff --git a/web/app/components/workflow/nodes/assigner/components/__tests__/operation-selector.spec.tsx b/web/app/components/workflow/nodes/assigner/components/__tests__/operation-selector.spec.tsx index 63813c8a46..e29b1e42a3 100644 --- a/web/app/components/workflow/nodes/assigner/components/__tests__/operation-selector.spec.tsx +++ b/web/app/components/workflow/nodes/assigner/components/__tests__/operation-selector.spec.tsx @@ -4,6 +4,75 @@ import { VarType } from '@/app/components/workflow/types' import { WriteMode } from '../../types' import OperationSelector from '../operation-selector' +vi.mock('@/app/components/base/ui/dropdown-menu', async () => { + const React = await import('react') + const DropdownMenuContext = React.createContext<{ open: boolean, setOpen: (open: boolean) => void } | null>(null) + + const useDropdownMenuContext = () => { + const context = React.use(DropdownMenuContext) + if (!context) + throw new Error('DropdownMenu components must be wrapped in DropdownMenu') + return context + } + + return { + DropdownMenu: ({ children, open, onOpenChange }: { children: React.ReactNode, open: boolean, onOpenChange?: (open: boolean) => void }) => ( + +
{children}
+
+ ), + DropdownMenuTrigger: ({ + children, + className, + disabled, + }: { + children: React.ReactNode + className?: string + disabled?: boolean + }) => { + const { open, setOpen } = useDropdownMenuContext() + return ( + + ) + }, + DropdownMenuContent: ({ children }: { children: React.ReactNode }) => { + const { open } = useDropdownMenuContext() + return open ?
{children}
: null + }, + DropdownMenuGroup: ({ children }: { children: React.ReactNode }) =>
{children}
, + DropdownMenuLabel: ({ children }: { children: React.ReactNode }) =>
{children}
, + DropdownMenuGroupLabel: ({ children }: { children: React.ReactNode }) =>
{children}
, + DropdownMenuSeparator: () =>
, + DropdownMenuItem: ({ + children, + onClick, + }: { + children: React.ReactNode + onClick?: React.MouseEventHandler + }) => { + const { setOpen } = useDropdownMenuContext() + return ( + + ) + }, + } +}) + describe('assigner/operation-selector', () => { it('shows numeric write modes and emits the selected operation', async () => { const user = userEvent.setup() diff --git a/web/app/components/workflow/nodes/assigner/components/operation-selector.tsx b/web/app/components/workflow/nodes/assigner/components/operation-selector.tsx index 8bce904b74..a22b8f5f3f 100644 --- a/web/app/components/workflow/nodes/assigner/components/operation-selector.tsx +++ b/web/app/components/workflow/nodes/assigner/components/operation-selector.tsx @@ -3,18 +3,17 @@ import type { WriteMode } from '../types' import type { Item } from '../utils' import type { VarType } from '@/app/components/workflow/types' import { cn } from '@langgenius/dify-ui/cn' -import { - RiArrowDownSLine, - RiCheckLine, -} from '@remixicon/react' import { useState } from 'react' import { useTranslation } from 'react-i18next' -import Divider from '@/app/components/base/divider' import { - PortalToFollowElem, - PortalToFollowElemContent, - PortalToFollowElemTrigger, -} from '@/app/components/base/portal-to-follow-elem' + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/app/components/base/ui/dropdown-menu' import { getOperationItems, isOperationItem } from '../utils' type OperationSelectorProps = { @@ -49,65 +48,57 @@ const OperationSelector: FC = ({ const selectedItem = items.find(item => item.value === value) return ( - - !disabled && setOpen(v => !v)} + -
-
- + - {selectedItem && isOperationItem(selectedItem) ? t(`nodes.assigner.operations.${selectedItem.name}`, { ns: 'workflow' }) : t('nodes.assigner.operations.title', { ns: 'workflow' })} - -
- + > + {selectedItem && isOperationItem(selectedItem) ? t(`nodes.assigner.operations.${selectedItem.name}`, { ns: 'workflow' }) : t('nodes.assigner.operations.title', { ns: 'workflow' })} +
-
+ + - -
-
-
-
{t('nodes.assigner.operations.title', { ns: 'workflow' })}
-
- {items.map(item => ( - !isOperationItem(item) - ? ( - - ) - : ( -
{ - onSelect(item) - setOpen(false) - }} - > -
- {t(`nodes.assigner.operations.${item.name}`, { ns: 'workflow' })} -
- {item.value === value && ( -
- -
- )} + + + {t('nodes.assigner.operations.title', { ns: 'workflow' })} + {items.map(item => ( + !isOperationItem(item) + ? ( + + ) + : ( + onSelect(item)} + > +
+ {t(`nodes.assigner.operations.${item.name}`, { ns: 'workflow' })}
- ) - ))} -
-
- - + {item.value === value && ( +
+ +
+ )} + + ) + ))} + + + ) } diff --git a/web/app/components/workflow/note-node/__tests__/index.spec.tsx b/web/app/components/workflow/note-node/__tests__/index.spec.tsx index 9814bb63f4..1bfa14ffb1 100644 --- a/web/app/components/workflow/note-node/__tests__/index.spec.tsx +++ b/web/app/components/workflow/note-node/__tests__/index.spec.tsx @@ -110,6 +110,8 @@ describe('NoteNode', () => { await waitFor(() => { expect(screen.getByText('workflow.nodes.note.editor.small')).toBeInTheDocument() }) + + expect(screen.getByText('workflow.nodes.note.editor.small').closest('.nodrag.nopan.nowheel')).toBeInTheDocument() }) it('should hide the toolbar for temporary notes', () => { diff --git a/web/app/components/workflow/note-node/index.tsx b/web/app/components/workflow/note-node/index.tsx index 5050041a05..fa69f05841 100644 --- a/web/app/components/workflow/note-node/index.tsx +++ b/web/app/components/workflow/note-node/index.tsx @@ -95,7 +95,7 @@ const NoteNode = ({
{ data.selected && !data._isTempNode && ( -
+
{ expect(onThemeChange).toHaveBeenCalledWith(NoteTheme.violet) - fireEvent.click(container.querySelectorAll('[data-state="closed"]')[container.querySelectorAll('[data-state="closed"]').length - 1] as HTMLElement) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.more' })) fireEvent.click(screen.getByText('workflow.common.copy')) expect(onCopy).toHaveBeenCalledTimes(1) diff --git a/web/app/components/workflow/note-node/note-editor/toolbar/__tests__/operator.spec.tsx b/web/app/components/workflow/note-node/note-editor/toolbar/__tests__/operator.spec.tsx index 1870bf913a..c60898b29e 100644 --- a/web/app/components/workflow/note-node/note-editor/toolbar/__tests__/operator.spec.tsx +++ b/web/app/components/workflow/note-node/note-editor/toolbar/__tests__/operator.spec.tsx @@ -1,13 +1,132 @@ -import { fireEvent, render, screen } from '@testing-library/react' +import type { + MouseEvent, + MouseEventHandler, + ReactElement, + ReactNode, +} from 'react' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import Operator from '../operator' +type DropdownTriggerRenderProps = { + 'className'?: string + 'role'?: string + 'aria-label'?: string + 'onMouseDown'?: MouseEventHandler + 'onClick'?: MouseEventHandler +} + +type DropdownTriggerProps = { + 'children': ReactNode + 'className'?: string + 'render'?: ReactElement + 'onMouseDown'?: MouseEventHandler + 'onClick'?: MouseEventHandler + 'aria-label'?: string +} + +vi.mock('@/app/components/base/ui/dropdown-menu', async () => { + const React = await import('react') + const DropdownMenuContext = React.createContext<{ open: boolean, setOpen: (open: boolean) => void } | null>(null) + + const useDropdownMenuContext = () => { + const context = React.use(DropdownMenuContext) + if (!context) + throw new Error('DropdownMenu components must be wrapped in DropdownMenu') + return context + } + + return { + DropdownMenu: ({ children, open, onOpenChange }: { children: ReactNode, open: boolean, onOpenChange?: (open: boolean) => void }) => ( + +
{children}
+
+ ), + DropdownMenuTrigger: ({ + children, + className, + render, + onMouseDown, + onClick, + 'aria-label': ariaLabel, + }: DropdownTriggerProps) => { + const { open, setOpen } = useDropdownMenuContext() + if (render) { + const handleMouseDown = (event: MouseEvent) => { + const baseUiEvent = event as MouseEvent & { preventBaseUIHandler?: () => void } + baseUiEvent.preventBaseUIHandler = vi.fn() + onMouseDown?.(baseUiEvent) + render.props.onMouseDown?.(event) + } + + const handleClick = (event: MouseEvent) => { + onClick?.(event) + render.props.onClick?.(event) + if (!onMouseDown) + setOpen(!open) + } + + return ( +
+ {children} +
+ ) + } + + return ( + + ) + }, + DropdownMenuContent: ({ children }: { children: ReactNode }) => { + const { open } = useDropdownMenuContext() + return open ?
{children}
: null + }, + DropdownMenuItem: ({ + children, + onClick, + className, + }: { + children: ReactNode + onClick?: MouseEventHandler + className?: string + }) => { + const { setOpen } = useDropdownMenuContext() + return ( + + ) + }, + DropdownMenuSeparator: ({ className }: { className?: string }) =>
, + } +}) + const renderOperator = (showAuthor = false) => { const onCopy = vi.fn() const onDuplicate = vi.fn() const onDelete = vi.fn() const onShowAuthorChange = vi.fn() - const renderResult = render( + render( { ) return { - ...renderResult, onCopy, onDelete, onDuplicate, @@ -27,41 +145,35 @@ const renderOperator = (showAuthor = false) => { } describe('NoteEditor Toolbar Operator', () => { - it('should trigger copy, duplicate, and delete from the opened menu', () => { + it('triggers copy, duplicate, and delete from the opened menu', async () => { + const user = userEvent.setup() const { - container, onCopy, onDelete, onDuplicate, } = renderOperator() - const trigger = container.querySelector('[data-state="closed"]') as HTMLElement - - fireEvent.click(trigger) - fireEvent.click(screen.getByText('workflow.common.copy')) - + await user.click(screen.getByRole('button', { name: 'common.operation.more' })) + await user.click(screen.getByText('workflow.common.copy')) expect(onCopy).toHaveBeenCalledTimes(1) - fireEvent.click(container.querySelector('[data-state="closed"]') as HTMLElement) - fireEvent.click(screen.getByText('workflow.common.duplicate')) - + await user.click(screen.getByRole('button', { name: 'common.operation.more' })) + await user.click(screen.getByText('workflow.common.duplicate')) expect(onDuplicate).toHaveBeenCalledTimes(1) - fireEvent.click(container.querySelector('[data-state="closed"]') as HTMLElement) - fireEvent.click(screen.getByText('common.operation.delete')) - + await user.click(screen.getByRole('button', { name: 'common.operation.more' })) + await user.click(screen.getByText('common.operation.delete')) expect(onDelete).toHaveBeenCalledTimes(1) }) - it('should forward the switch state through onShowAuthorChange', () => { - const { - container, - onShowAuthorChange, - } = renderOperator(true) + it('keeps the menu open when toggling show author', async () => { + const user = userEvent.setup() + const { onShowAuthorChange } = renderOperator(true) - fireEvent.click(container.querySelector('[data-state="closed"]') as HTMLElement) - fireEvent.click(screen.getByRole('switch')) + await user.click(screen.getByRole('button', { name: 'common.operation.more' })) + await user.click(screen.getByRole('switch')) expect(onShowAuthorChange).toHaveBeenCalledWith(false) + expect(screen.getByText('workflow.nodes.note.editor.showAuthor')).toBeInTheDocument() }) }) diff --git a/web/app/components/workflow/note-node/note-editor/toolbar/index.tsx b/web/app/components/workflow/note-node/note-editor/toolbar/index.tsx index c7b8fa9787..f00d3464ac 100644 --- a/web/app/components/workflow/note-node/note-editor/toolbar/index.tsx +++ b/web/app/components/workflow/note-node/note-editor/toolbar/index.tsx @@ -18,7 +18,11 @@ const Toolbar = ({ onShowAuthorChange, }: ToolbarProps) => { return ( -
+
event.stopPropagation()} + onClick={event => event.stopPropagation()} + > - setOpen(!open)}> -
- -
-
- + } + aria-label={t('operation.more', { ns: 'common' })} + className={cn( + 'flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary', + open && 'bg-state-base-hover text-text-secondary', + )} + onMouseDown={(event) => { + event.preventDefault() + event.stopPropagation() + ;(event as typeof event & { preventBaseUIHandler?: () => void }).preventBaseUIHandler?.() + setOpen(prev => !prev) + }} + onClick={event => event.stopPropagation()} + > + + +
-
{ - onCopy() setOpen(false) + onCopy() }} > {t('common.copy', { ns: 'workflow' })} -
-
+ { - onDuplicate() setOpen(false) + onDuplicate() }} > {t('common.duplicate', { ns: 'workflow' })} -
+
-
+
-
+
-
{ - onDelete() setOpen(false) + onDelete() }} > {t('operation.delete', { ns: 'common' })} -
+
-
- + + ) } diff --git a/web/app/components/workflow/operator/__tests__/more-actions.spec.tsx b/web/app/components/workflow/operator/__tests__/more-actions.spec.tsx new file mode 100644 index 0000000000..b984ca18d6 --- /dev/null +++ b/web/app/components/workflow/operator/__tests__/more-actions.spec.tsx @@ -0,0 +1,309 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { act } from 'react' +import MoreActions from '../more-actions' + +const mockToPng = vi.fn() +const mockToJpeg = vi.fn() +const mockToSvg = vi.fn() +const mockDownloadUrl = vi.fn() +const mockSetViewport = vi.fn() +const mockGetNodesReadOnly = vi.fn() +const { + mockAppStoreState, + mockWorkflowState, +} = vi.hoisted(() => ({ + mockAppStoreState: { + appSidebarExpand: 'collapse', + }, + mockWorkflowState: { + knowledgeName: '', + appName: 'Demo App', + maximizeCanvas: false, + }, +})) + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, options?: { ns?: string }) => options?.ns ? `${options.ns}.${key}` : key, + }), +})) + +vi.mock('@/app/components/base/ui/dropdown-menu', async () => { + const React = await import('react') + const DropdownMenuContext = React.createContext<{ open: boolean, setOpen: (open: boolean) => void } | null>(null) + + const useDropdownMenuContext = () => { + const context = React.use(DropdownMenuContext) + if (!context) + throw new Error('DropdownMenu components must be wrapped in DropdownMenu') + return context + } + + return { + DropdownMenu: ({ children, open, onOpenChange }: { children: React.ReactNode, open: boolean, onOpenChange?: (open: boolean) => void }) => ( + +
{children}
+
+ ), + DropdownMenuTrigger: ({ children, className }: { children: React.ReactNode, className?: string }) => { + const { open, setOpen } = useDropdownMenuContext() + return ( + + ) + }, + DropdownMenuContent: ({ children }: { children: React.ReactNode }) => { + const { open } = useDropdownMenuContext() + return open ?
{children}
: null + }, + DropdownMenuItem: ({ + children, + onClick, + className, + }: { + children: React.ReactNode + onClick?: React.MouseEventHandler + className?: string + }) => { + const { setOpen } = useDropdownMenuContext() + return ( + + ) + }, + DropdownMenuSeparator: ({ className }: { className?: string }) =>
, + } +}) + +vi.mock('html-to-image', () => ({ + toPng: (...args: unknown[]) => mockToPng(...args), + toJpeg: (...args: unknown[]) => mockToJpeg(...args), + toSvg: (...args: unknown[]) => mockToSvg(...args), +})) + +vi.mock('reactflow', () => ({ + getNodesBounds: () => ({ x: 0, y: 0, width: 240, height: 120 }), + useReactFlow: () => ({ + getNodes: () => [{ id: 'node-1' }], + getViewport: () => ({ x: 0, y: 0, zoom: 1 }), + setViewport: mockSetViewport, + }), +})) + +vi.mock('@/app/components/app/store', () => ({ + useStore: (selector: (state: typeof mockAppStoreState) => unknown) => selector(mockAppStoreState), +})) + +vi.mock('@/app/components/workflow/store', () => ({ + useStore: (selector: (state: typeof mockWorkflowState) => unknown) => selector(mockWorkflowState), +})) + +vi.mock('@/app/components/workflow/hooks', () => ({ + useNodesReadOnly: () => ({ + getNodesReadOnly: mockGetNodesReadOnly, + }), +})) + +vi.mock('@/utils/download', () => ({ + downloadUrl: (...args: unknown[]) => mockDownloadUrl(...args), +})) + +vi.mock('../tip-popup', () => ({ + default: ({ children }: { children: React.ReactNode }) => <>{children}, +})) + +vi.mock('@/app/components/base/image-uploader/image-preview', () => ({ + default: ({ title, onCancel }: { title: string, onCancel: () => void }) => ( +
+ {title} + +
+ ), +})) + +describe('MoreActions', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.useRealTimers() + mockGetNodesReadOnly.mockReturnValue(false) + mockToPng.mockResolvedValue('data:image/png;base64,current') + mockToJpeg.mockResolvedValue('data:image/jpeg;base64,current') + mockToSvg.mockResolvedValue('data:image/svg+xml;base64,current') + mockAppStoreState.appSidebarExpand = 'collapse' + mockWorkflowState.knowledgeName = '' + mockWorkflowState.appName = 'Demo App' + mockWorkflowState.maximizeCanvas = false + + document.body.innerHTML = '' + const viewport = document.createElement('div') + viewport.className = 'react-flow__viewport' + document.body.appendChild(viewport) + }) + + it('opens the menu and exports the current view as png', async () => { + const user = userEvent.setup() + + render() + + await user.click(screen.getByRole('button')) + await user.click(screen.getAllByText('workflow.common.exportPNG')[0]!) + + await waitFor(() => { + expect(mockToPng).toHaveBeenCalledTimes(1) + }) + expect(mockDownloadUrl).toHaveBeenCalledWith({ + url: 'data:image/png;base64,current', + fileName: 'Demo App.png', + }) + }) + + it('does not open the menu when the workflow is read only', async () => { + const user = userEvent.setup() + mockGetNodesReadOnly.mockReturnValue(true) + + render() + + await user.click(screen.getByRole('button')) + + expect(screen.queryByText('workflow.common.exportImage')).not.toBeInTheDocument() + }) + + it('shows a preview when exporting the whole workflow', async () => { + vi.useFakeTimers() + + render() + + fireEvent.click(screen.getByRole('button')) + fireEvent.click(screen.getAllByText('workflow.common.exportPNG')[1]!) + await act(async () => { + await vi.advanceTimersByTimeAsync(300) + }) + + expect(screen.getByTestId('image-preview')).toHaveTextContent('Demo App-whole-workflow.png') + await act(async () => { + await vi.runAllTimersAsync() + }) + expect(mockSetViewport).toHaveBeenCalledTimes(2) + expect(mockDownloadUrl).toHaveBeenCalledWith({ + url: 'data:image/png;base64,current', + fileName: 'Demo App-whole-workflow.png', + }) + }) + + it.each([ + ['workflow.common.exportJPEG', mockToJpeg, 'Demo App.jpeg'], + ['workflow.common.exportSVG', mockToSvg, 'Demo App.svg'], + ])('exports the current view with %s', async (label, exporter, fileName) => { + const user = userEvent.setup() + + render() + + await user.click(screen.getByRole('button')) + await user.click(screen.getAllByText(label)[0]!) + + await waitFor(() => { + expect(exporter).toHaveBeenCalledTimes(1) + }) + expect(mockDownloadUrl).toHaveBeenCalledWith({ + url: expect.any(String), + fileName, + }) + }) + + it('exports the whole workflow as svg when the canvas is maximized', async () => { + vi.useFakeTimers() + mockWorkflowState.maximizeCanvas = true + + render() + + fireEvent.click(screen.getByRole('button')) + fireEvent.click(screen.getAllByText('workflow.common.exportSVG')[1]!) + await act(async () => { + await vi.advanceTimersByTimeAsync(300) + }) + + expect(mockToSvg).toHaveBeenCalledTimes(1) + await act(async () => { + await vi.runAllTimersAsync() + }) + expect(mockSetViewport).toHaveBeenCalledTimes(2) + expect(screen.getByTestId('image-preview')).toHaveTextContent('Demo App-whole-workflow.svg') + }) + + it('returns early when there is no app or knowledge name', async () => { + const user = userEvent.setup() + mockWorkflowState.appName = '' + mockWorkflowState.knowledgeName = '' + + render() + + await user.click(screen.getByRole('button')) + await user.click(screen.getAllByText('workflow.common.exportPNG')[0]!) + + expect(mockToPng).not.toHaveBeenCalled() + expect(mockDownloadUrl).not.toHaveBeenCalled() + }) + + it('returns early when the viewport element is missing', async () => { + const user = userEvent.setup() + document.querySelector('.react-flow__viewport')?.remove() + + render() + + await user.click(screen.getByRole('button')) + await user.click(screen.getAllByText('workflow.common.exportPNG')[0]!) + + expect(mockToPng).not.toHaveBeenCalled() + expect(mockDownloadUrl).not.toHaveBeenCalled() + }) + + it('returns early when the workflow becomes read only before exporting', async () => { + const user = userEvent.setup() + + render() + + await user.click(screen.getByRole('button')) + mockGetNodesReadOnly.mockReturnValue(true) + await user.click(screen.getAllByText('workflow.common.exportJPEG')[0]!) + + expect(mockToJpeg).not.toHaveBeenCalled() + expect(mockDownloadUrl).not.toHaveBeenCalled() + }) + + it('logs export failures and lets the preview close', async () => { + const user = userEvent.setup() + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + mockToJpeg.mockRejectedValueOnce(new Error('boom')) + + render() + + await user.click(screen.getByRole('button')) + await user.click(screen.getAllByText('workflow.common.exportJPEG')[0]!) + + await waitFor(() => { + expect(consoleErrorSpy).toHaveBeenCalledWith('Export image failed:', expect.any(Error)) + }) + expect(screen.queryByTestId('image-preview')).not.toBeInTheDocument() + + mockToPng.mockResolvedValueOnce('data:image/png;base64,current') + fireEvent.click(screen.getByRole('button')) + fireEvent.click(screen.getAllByText('workflow.common.exportPNG')[1]!) + await waitFor(() => { + expect(screen.getByTestId('image-preview')).toBeInTheDocument() + }) + await user.click(screen.getByText('close-preview')) + expect(screen.queryByTestId('image-preview')).not.toBeInTheDocument() + + consoleErrorSpy.mockRestore() + }) +}) diff --git a/web/app/components/workflow/operator/__tests__/zoom-in-out.spec.tsx b/web/app/components/workflow/operator/__tests__/zoom-in-out.spec.tsx new file mode 100644 index 0000000000..8b0f12c7d6 --- /dev/null +++ b/web/app/components/workflow/operator/__tests__/zoom-in-out.spec.tsx @@ -0,0 +1,197 @@ +import { fireEvent, render, screen, within } from '@testing-library/react' +import ZoomInOut from '../zoom-in-out' + +const { + mockZoomIn, + mockZoomOut, + mockZoomTo, + mockFitView, + mockViewport, + mockHandleSyncWorkflowDraft, + mockToggleMiniMap, + mockToggleUserComments, + mockToggleUserCursors, +} = vi.hoisted(() => ({ + mockZoomIn: vi.fn(), + mockZoomOut: vi.fn(), + mockZoomTo: vi.fn(), + mockFitView: vi.fn(), + mockViewport: { zoom: 1 }, + mockHandleSyncWorkflowDraft: vi.fn(), + mockToggleMiniMap: vi.fn(), + mockToggleUserComments: vi.fn(), + mockToggleUserCursors: vi.fn(), +})) + +let workflowReadOnly = false +let collaborationEnabled = true + +vi.mock('reactflow', () => ({ + useReactFlow: () => ({ + zoomIn: mockZoomIn, + zoomOut: mockZoomOut, + zoomTo: mockZoomTo, + fitView: mockFitView, + }), + useViewport: () => mockViewport, +})) + +vi.mock('@/app/components/workflow/hooks', () => ({ + useNodesSyncDraft: () => ({ + handleSyncWorkflowDraft: mockHandleSyncWorkflowDraft, + }), + useWorkflowReadOnly: () => ({ + workflowReadOnly, + getWorkflowReadOnly: () => workflowReadOnly, + }), +})) + +vi.mock('@/context/global-public-context', () => ({ + useGlobalPublicStore: (selector: (state: { systemFeatures: { enable_collaboration_mode: boolean } }) => unknown) => selector({ + systemFeatures: { + enable_collaboration_mode: collaborationEnabled, + }, + }), +})) + +vi.mock('../tip-popup', () => ({ + default: ({ children }: { children: React.ReactNode }) => <>{children}, +})) + +const getZoomControls = () => { + const label = Array.from(document.querySelectorAll('button')).find((element) => { + return /^\d+%$/.test(element.textContent ?? '') && element.className.includes('w-[34px]') + }) + const zoomOutIcon = document.querySelector('.i-ri-zoom-out-line') + const zoomInIcon = document.querySelector('.i-ri-zoom-in-line') + + if (!label || !zoomOutIcon || !zoomInIcon) + throw new Error('Missing zoom controls') + + return { + zoomOutTrigger: zoomOutIcon.parentElement as HTMLElement, + label, + zoomInTrigger: zoomInIcon.parentElement as HTMLElement, + } +} + +const openZoomMenu = () => { + fireEvent.click(getZoomControls().label) + return within(screen.getByRole('menu')) +} + +describe('workflow zoom controls', () => { + beforeEach(() => { + vi.clearAllMocks() + mockViewport.zoom = 1 + workflowReadOnly = false + collaborationEnabled = true + }) + + it('zooms out and zooms in when the viewport is within the supported range', () => { + render() + + const { zoomOutTrigger, zoomInTrigger } = getZoomControls() + + fireEvent.click(zoomOutTrigger) + fireEvent.click(zoomInTrigger) + + expect(mockZoomOut).toHaveBeenCalledTimes(1) + expect(mockZoomIn).toHaveBeenCalledTimes(1) + }) + + it('zooms to a preset value and syncs the draft', () => { + render() + + const menu = openZoomMenu() + fireEvent.click(menu.getByText('50%')) + + expect(mockZoomTo).toHaveBeenCalledWith(0.5) + expect(mockHandleSyncWorkflowDraft).toHaveBeenCalledTimes(1) + }) + + it.each([ + ['100%', 1], + ['200%', 2], + ])('zooms to %s and syncs the draft', (label, zoom) => { + render() + + const menu = openZoomMenu() + fireEvent.click(menu.getByText(label)) + + expect(mockZoomTo).toHaveBeenCalledWith(zoom) + expect(mockHandleSyncWorkflowDraft).toHaveBeenCalledTimes(1) + }) + + it('toggles collaboration options without syncing the draft', () => { + render( + , + ) + + let menu = openZoomMenu() + fireEvent.click(menu.getByText('workflow.operator.showMiniMap')) + expect(mockToggleMiniMap).toHaveBeenCalledTimes(1) + expect(mockHandleSyncWorkflowDraft).not.toHaveBeenCalled() + + menu = openZoomMenu() + fireEvent.click(menu.getByText('workflow.operator.showUserComments')) + expect(mockToggleUserComments).toHaveBeenCalledTimes(1) + + menu = openZoomMenu() + fireEvent.click(menu.getByText('workflow.operator.showUserCursors')) + expect(mockToggleUserCursors).toHaveBeenCalledTimes(1) + }) + + it('keeps the show-user-comments action disabled in comment mode', () => { + render( + , + ) + + const menu = openZoomMenu() + fireEvent.click(menu.getByText('workflow.operator.showUserComments')) + + expect(mockToggleUserComments).not.toHaveBeenCalled() + }) + + it('does not open the menu when the workflow is read only', () => { + workflowReadOnly = true + render() + + fireEvent.click(getZoomControls().label) + + expect(screen.queryByRole('menu')).not.toBeInTheDocument() + }) + + it('blocks inline zooming out at the minimum viewport scale', () => { + mockViewport.zoom = 0.25 + render() + + fireEvent.click(getZoomControls().zoomOutTrigger) + expect(mockZoomOut).not.toHaveBeenCalled() + }) + + it('blocks inline zooming in at the maximum viewport scale', () => { + mockViewport.zoom = 2 + render() + + fireEvent.click(getZoomControls().zoomInTrigger) + expect(mockZoomIn).not.toHaveBeenCalled() + }) + + it('renders collaboration menu entries only when collaboration is enabled', () => { + collaborationEnabled = false + render() + + const menu = openZoomMenu() + expect(menu.getByText('workflow.operator.showMiniMap')).toBeInTheDocument() + expect(menu.queryByText('workflow.operator.showUserComments')).not.toBeInTheDocument() + expect(menu.queryByText('workflow.operator.showUserCursors')).not.toBeInTheDocument() + }) +}) diff --git a/web/app/components/workflow/operator/more-actions.tsx b/web/app/components/workflow/operator/more-actions.tsx index 5e71cc658b..66dbed1a91 100644 --- a/web/app/components/workflow/operator/more-actions.tsx +++ b/web/app/components/workflow/operator/more-actions.tsx @@ -14,10 +14,12 @@ import { useShallow } from 'zustand/react/shallow' import { useStore as useAppStore } from '@/app/components/app/store' import ImagePreview from '@/app/components/base/image-uploader/image-preview' import { - PortalToFollowElem, - PortalToFollowElemContent, - PortalToFollowElemTrigger, -} from '@/app/components/base/portal-to-follow-elem' + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/app/components/base/ui/dropdown-menu' import { useStore } from '@/app/components/workflow/store' import { downloadUrl } from '@/utils/download' import { useNodesReadOnly } from '../hooks' @@ -37,6 +39,7 @@ const MoreActions: FC = () => { const { appSidebarExpand } = useAppStore(useShallow(state => ({ appSidebarExpand: state.appSidebarExpand, }))) + const isReadOnly = getNodesReadOnly() const crossAxisOffset = useMemo(() => { if (maximizeCanvas) @@ -161,93 +164,67 @@ const MoreActions: FC = () => { } }, [getNodesReadOnly, appName, reactFlow, knowledgeName]) - const handleTrigger = useCallback(() => { - if (getNodesReadOnly()) - return - - setOpen(v => !v) - }, [getNodesReadOnly]) - return ( <> - { + if (isReadOnly) { + setOpen(false) + return + } + setOpen(nextOpen) }} > - + -
- -
+
-
- -
-
-
- - {t('common.exportImage', { ns: 'workflow' })} -
-
- {t('common.currentView', { ns: 'workflow' })} -
-
handleExportImage('png')} - > - {t('common.exportPNG', { ns: 'workflow' })} -
-
handleExportImage('jpeg')} - > - {t('common.exportJPEG', { ns: 'workflow' })} -
-
handleExportImage('svg')} - > - {t('common.exportSVG', { ns: 'workflow' })} -
- -
- -
- {t('common.currentWorkflow', { ns: 'workflow' })} -
-
handleExportImage('png', true)} - > - {t('common.exportPNG', { ns: 'workflow' })} -
-
handleExportImage('jpeg', true)} - > - {t('common.exportJPEG', { ns: 'workflow' })} -
-
handleExportImage('svg', true)} - > - {t('common.exportSVG', { ns: 'workflow' })} -
-
+ + +
+ + {t('common.exportImage', { ns: 'workflow' })}
- - +
+ {t('common.currentView', { ns: 'workflow' })} +
+ handleExportImage('png')}> + {t('common.exportPNG', { ns: 'workflow' })} + + handleExportImage('jpeg')}> + {t('common.exportJPEG', { ns: 'workflow' })} + + handleExportImage('svg')}> + {t('common.exportSVG', { ns: 'workflow' })} + + + + +
+ {t('common.currentWorkflow', { ns: 'workflow' })} +
+ handleExportImage('png', true)}> + {t('common.exportPNG', { ns: 'workflow' })} + + handleExportImage('jpeg', true)}> + {t('common.exportJPEG', { ns: 'workflow' })} + + handleExportImage('svg', true)}> + {t('common.exportSVG', { ns: 'workflow' })} + +
+ {previewUrl && ( = ({ } = useWorkflowReadOnly() const isCollaborationEnabled = useGlobalPublicStore(s => s.systemFeatures.enable_collaboration_mode) - const ZOOM_IN_OUT_OPTIONS = [ + const zoomOptions = [ [ { key: ZoomType.zoomTo200, @@ -135,6 +126,8 @@ const ZoomInOut: FC = ({ if (workflowReadOnly) return + setOpen(false) + if (type === ZoomType.zoomToFit) fitView() @@ -173,154 +166,134 @@ const ZoomInOut: FC = ({ handleSyncWorkflowDraft() } - const handleTrigger = useCallback(() => { - if (getWorkflowReadOnly()) - return - - setOpen(v => !v) - }, [getWorkflowReadOnly]) - return ( - - -
+ -
{ + if (zoom <= 0.25) + return + + e.stopPropagation() + zoomOut() + }} > - -
{ - if (zoom <= 0.25) - return - - e.stopPropagation() - zoomOut() - }} - > - -
-
-
- {Number.parseFloat(`${zoom * 100}`).toFixed(0)} - % -
- -
= 2 ? 'cursor-not-allowed' : 'cursor-pointer hover:bg-black/5'}`} - onClick={(e) => { - if (zoom >= 2) - return - - e.stopPropagation() - zoomIn() - }} - > - -
-
+
-
-
- -
- { - ZOOM_IN_OUT_OPTIONS.map((options, i) => ( - - { - i !== 0 && ( - - ) - } -
- { - options.map(option => ( -
+ + + {Number.parseFloat(`${zoom * 100}`).toFixed(0)} + % + + +
+ {zoomOptions.map((options, groupIndex) => ( + + {groupIndex !== 0 && ( + + )} +
+ {options.map(option => ( + handleZoom(option.key)} > -
+
{option.key === ZoomType.toggleUserComments && showUserComments && ( - + )} {option.key === ZoomType.toggleUserComments && !showUserComments && ( -
+ )} {option.key === ZoomType.toggleUserCursors && showUserCursors && ( - + )} {option.key === ZoomType.toggleUserCursors && !showUserCursors && ( -
+ )} {option.key === ZoomType.toggleMiniMap && showMiniMap && ( - + )} {option.key === ZoomType.toggleMiniMap && !showMiniMap && ( -
+ )} {option.key === ZoomType.zoomToFit && ( - + )} {option.key !== ZoomType.toggleUserComments && option.key !== ZoomType.toggleUserCursors && option.key !== ZoomType.toggleMiniMap && option.key !== ZoomType.zoomToFit && ( -
+ )} {option.text}
- { - option.key === ZoomType.zoomToFit && ( - - ) - } - { - option.key === ZoomType.zoomTo50 && ( - - ) - } - { - option.key === ZoomType.zoomTo100 && ( - - ) - } + {option.key === ZoomType.zoomToFit && ( + + )} + {option.key === ZoomType.zoomTo50 && ( + + )} + {option.key === ZoomType.zoomTo100 && ( + + )}
-
- )) - } -
- - )) - } -
- - + + ))} +
+ + ))} +
+ + + +
= 2 ? 'cursor-not-allowed' : 'cursor-pointer hover:bg-black/5'}`} + onClick={(e) => { + if (zoom >= 2) + return + + e.stopPropagation() + zoomIn() + }} + > + +
+
+
+
) } diff --git a/web/app/components/workflow/panel/version-history-panel/context-menu/__tests__/menu-item.spec.tsx b/web/app/components/workflow/panel/version-history-panel/context-menu/__tests__/menu-item.spec.tsx index 02b1f8ee34..844e189067 100644 --- a/web/app/components/workflow/panel/version-history-panel/context-menu/__tests__/menu-item.spec.tsx +++ b/web/app/components/workflow/panel/version-history-panel/context-menu/__tests__/menu-item.spec.tsx @@ -1,5 +1,6 @@ import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' +import { DropdownMenu, DropdownMenuContent } from '@/app/components/base/ui/dropdown-menu' import { VersionHistoryContextMenuOptions } from '../../../../types' import MenuItem from '../menu-item' @@ -9,14 +10,18 @@ describe('MenuItem', () => { const onClick = vi.fn() render( - , + + + + + , ) await user.click(screen.getByText('Delete')) diff --git a/web/app/components/workflow/panel/version-history-panel/context-menu/index.tsx b/web/app/components/workflow/panel/version-history-panel/context-menu/index.tsx index a635e80bab..f063902753 100644 --- a/web/app/components/workflow/panel/version-history-panel/context-menu/index.tsx +++ b/web/app/components/workflow/panel/version-history-panel/context-menu/index.tsx @@ -1,14 +1,13 @@ import type { FC } from 'react' import { RiMoreFill } from '@remixicon/react' import * as React from 'react' -import { useCallback } from 'react' -import Divider from '@/app/components/base/divider' -import { - PortalToFollowElem, - PortalToFollowElemContent, - PortalToFollowElemTrigger, -} from '@/app/components/base/portal-to-follow-elem' import { Button } from '@/app/components/base/ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/app/components/base/ui/dropdown-menu' import { VersionHistoryContextMenuOptions } from '../../../types' import MenuItem from './menu-item' import useContextMenu from './use-context-menu' @@ -28,58 +27,44 @@ const ContextMenu: FC = (props: ContextMenuProps) => { options, } = useContextMenu(props) - const handleClickTrigger = useCallback((e: React.MouseEvent) => { - e.stopPropagation() - setOpen(v => !v) - }, [setOpen]) - return ( - - - - - -
-
- { - options.map((option) => { - return ( - - ) - }) - } -
- { - isShowDelete && ( - <> - -
- -
- - ) - } -
-
-
+ e.stopPropagation()} />} + > + + + + { + options.map(option => ( + + )) + } + { + isShowDelete && ( + <> + + + + ) + } + +
) } diff --git a/web/app/components/workflow/panel/version-history-panel/context-menu/menu-item.tsx b/web/app/components/workflow/panel/version-history-panel/context-menu/menu-item.tsx index 5a3f21272f..b1d9ef6ec5 100644 --- a/web/app/components/workflow/panel/version-history-panel/context-menu/menu-item.tsx +++ b/web/app/components/workflow/panel/version-history-panel/context-menu/menu-item.tsx @@ -2,6 +2,7 @@ import type { FC } from 'react' import type { VersionHistoryContextMenuOptions } from '../../../types' import { cn } from '@langgenius/dify-ui/cn' import * as React from 'react' +import { DropdownMenuItem } from '@/app/components/base/ui/dropdown-menu' type MenuItemProps = { item: { @@ -18,23 +19,25 @@ const MenuItem: FC = ({ isDestructive = false, }) => { return ( -
{ + onClick={(event) => { + event.stopPropagation() onClick(item.key) }} >
{item.name}
-
+ ) } diff --git a/web/app/components/workflow/run/agent-log/__tests__/agent-log-nav-more.spec.tsx b/web/app/components/workflow/run/agent-log/__tests__/agent-log-nav-more.spec.tsx index d109635af4..35b32a5ce6 100644 --- a/web/app/components/workflow/run/agent-log/__tests__/agent-log-nav-more.spec.tsx +++ b/web/app/components/workflow/run/agent-log/__tests__/agent-log-nav-more.spec.tsx @@ -3,6 +3,52 @@ import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import AgentLogNavMore from '../agent-log-nav-more' +vi.mock('@/app/components/base/ui/dropdown-menu', async () => { + const React = await import('react') + const DropdownMenuContext = React.createContext<{ open: boolean, setOpen: (open: boolean) => void } | null>(null) + + const useDropdownMenuContext = () => { + const context = React.use(DropdownMenuContext) + if (!context) + throw new Error('DropdownMenu components must be wrapped in DropdownMenu') + return context + } + + return { + DropdownMenu: ({ children, open, onOpenChange }: { children: React.ReactNode, open: boolean, onOpenChange?: (open: boolean) => void }) => ( + +
{children}
+
+ ), + DropdownMenuTrigger: ({ children, render }: { children: React.ReactNode, render?: React.ReactElement }) => { + const { open, setOpen } = useDropdownMenuContext() + + if (render) + return React.cloneElement(render, { onClick: () => setOpen(!open) } as Record, children) + + return + }, + DropdownMenuContent: ({ children }: { children: React.ReactNode }) => { + const { open } = useDropdownMenuContext() + return open ?
{children}
: null + }, + DropdownMenuItem: ({ children, onClick }: { children: React.ReactNode, onClick?: React.MouseEventHandler }) => { + const { setOpen } = useDropdownMenuContext() + return ( + + ) + }, + } +}) + const createLogItem = (overrides: Partial = {}): AgentLogItemWithChildren => ({ message_id: 'message-1', label: 'Planner', diff --git a/web/app/components/workflow/run/agent-log/agent-log-nav-more.tsx b/web/app/components/workflow/run/agent-log/agent-log-nav-more.tsx index 8bdb6ad227..77f3c778a6 100644 --- a/web/app/components/workflow/run/agent-log/agent-log-nav-more.tsx +++ b/web/app/components/workflow/run/agent-log/agent-log-nav-more.tsx @@ -1,12 +1,13 @@ import type { AgentLogItemWithChildren } from '@/types/workflow' import { RiMoreLine } from '@remixicon/react' import { useState } from 'react' -import { - PortalToFollowElem, - PortalToFollowElemContent, - PortalToFollowElemTrigger, -} from '@/app/components/base/portal-to-follow-elem' import { Button } from '@/app/components/base/ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/app/components/base/ui/dropdown-menu' type AgentLogNavMoreProps = { options: AgentLogItemWithChildren[] @@ -19,42 +20,39 @@ const AgentLogNavMore = ({ const [open, setOpen] = useState(false) return ( - - setOpen(v => !v)}> - - - -
- { - options.map(option => ( -
{ - onShowAgentOrToolLog(option) - setOpen(false) - }} - > - {option.label} -
- )) - } -
-
-
+ + )} + > + + + + { + options.map(option => ( + onShowAgentOrToolLog(option)} + > + {option.label} + + )) + } + + ) } diff --git a/web/app/components/workflow/workflow-preview/components/__tests__/zoom-in-out.spec.tsx b/web/app/components/workflow/workflow-preview/components/__tests__/zoom-in-out.spec.tsx index 4991ae7b04..6e8ad90933 100644 --- a/web/app/components/workflow/workflow-preview/components/__tests__/zoom-in-out.spec.tsx +++ b/web/app/components/workflow/workflow-preview/components/__tests__/zoom-in-out.spec.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, within } from '@testing-library/react' +import { fireEvent, render, screen, within } from '@testing-library/react' import ZoomInOut from '../zoom-in-out' const { @@ -26,29 +26,25 @@ vi.mock('reactflow', () => ({ })) const getZoomControls = () => { - const label = Array.from(document.querySelectorAll('div')).find((element) => { + const label = Array.from(document.querySelectorAll('button')).find((element) => { return /^\d+%$/.test(element.textContent ?? '') && element.className.includes('w-[34px]') }) - const icons = Array.from(document.querySelectorAll('svg')) + const zoomOutIcon = document.querySelector('.i-ri-zoom-out-line') + const zoomInIcon = document.querySelector('.i-ri-zoom-in-line') - if (!label || icons.length < 2) + if (!label || !zoomOutIcon || !zoomInIcon) throw new Error('Missing zoom controls') return { - zoomOutTrigger: icons[0]!.parentElement as HTMLElement, + zoomOutTrigger: zoomOutIcon.parentElement as HTMLElement, label, - zoomInTrigger: icons[1]!.parentElement as HTMLElement, + zoomInTrigger: zoomInIcon.parentElement as HTMLElement, } } const openZoomMenu = () => { fireEvent.click(getZoomControls().label) - - const portal = document.querySelector('[data-floating-ui-portal]') - if (!portal) - throw new Error('Missing zoom menu portal') - - return within(portal as HTMLElement) + return within(screen.getByRole('menu')) } describe('workflow preview zoom controls', () => { diff --git a/web/app/components/workflow/workflow-preview/components/zoom-in-out.tsx b/web/app/components/workflow/workflow-preview/components/zoom-in-out.tsx index 7a81bccc50..3f714c5ca9 100644 --- a/web/app/components/workflow/workflow-preview/components/zoom-in-out.tsx +++ b/web/app/components/workflow/workflow-preview/components/zoom-in-out.tsx @@ -1,13 +1,8 @@ import type { FC } from 'react' import { cn } from '@langgenius/dify-ui/cn' -import { - RiZoomInLine, - RiZoomOutLine, -} from '@remixicon/react' import { Fragment, memo, - useCallback, useState, } from 'react' import { useTranslation } from 'react-i18next' @@ -15,18 +10,17 @@ import { useReactFlow, useViewport, } from 'reactflow' -import Divider from '@/app/components/base/divider' import { - PortalToFollowElem, - PortalToFollowElemContent, - PortalToFollowElemTrigger, -} from '@/app/components/base/portal-to-follow-elem' + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/app/components/base/ui/dropdown-menu' import TipPopup from '@/app/components/workflow/operator/tip-popup' import ShortcutsName from '@/app/components/workflow/shortcuts-name' enum ZoomType { - zoomIn = 'zoomIn', - zoomOut = 'zoomOut', zoomToFit = 'zoomToFit', zoomTo25 = 'zoomTo25', zoomTo50 = 'zoomTo50', @@ -46,7 +40,7 @@ const ZoomInOut: FC = () => { const { zoom } = useViewport() const [open, setOpen] = useState(false) - const ZOOM_IN_OUT_OPTIONS = [ + const zoomOptions = [ [ { key: ZoomType.zoomTo200, @@ -78,6 +72,8 @@ const ZoomInOut: FC = () => { ] const handleZoom = (type: string) => { + setOpen(false) + if (type === ZoomType.zoomToFit) fitView() @@ -97,119 +93,98 @@ const ZoomInOut: FC = () => { zoomTo(2) } - const handleTrigger = useCallback(() => { - setOpen(v => !v) - }, []) - return ( - - -
+ -
{ + if (zoom <= 0.25) + return + + e.stopPropagation() + zoomOut() + }} > - -
{ - if (zoom <= 0.25) - return - - e.stopPropagation() - zoomOut() - }} - > - -
-
-
- {Number.parseFloat(`${zoom * 100}`).toFixed(0)} - % -
- -
= 2 ? 'cursor-not-allowed' : 'cursor-pointer hover:bg-black/5'}`} - onClick={(e) => { - if (zoom >= 2) - return - - e.stopPropagation() - zoomIn() - }} - > - -
-
+
-
-
- -
- { - ZOOM_IN_OUT_OPTIONS.map((options, i) => ( - - { - i !== 0 && ( - - ) - } -
- { - options.map(option => ( -
+ + + {Number.parseFloat(`${zoom * 100}`).toFixed(0)} + % + + +
+ {zoomOptions.map((options, groupIndex) => ( + + {groupIndex !== 0 && ( + + )} +
+ {options.map(option => ( + handleZoom(option.key)} > {option.text}
- { - option.key === ZoomType.zoomToFit && ( - - ) - } - { - option.key === ZoomType.zoomTo50 && ( - - ) - } - { - option.key === ZoomType.zoomTo100 && ( - - ) - } + {option.key === ZoomType.zoomToFit && ( + + )} + {option.key === ZoomType.zoomTo50 && ( + + )} + {option.key === ZoomType.zoomTo100 && ( + + )}
-
- )) - } -
- - )) - } -
- - + + ))} +
+
+ ))} +
+ + + +
= 2 ? 'cursor-not-allowed' : 'cursor-pointer hover:bg-black/5'}`} + onClick={(e) => { + if (zoom >= 2) + return + + e.stopPropagation() + zoomIn() + }} + > + +
+
+
+
) } diff --git a/web/docs/overlay-migration.md b/web/docs/overlay-migration.md index 6762c2bfb1..29ef814fcd 100644 --- a/web/docs/overlay-migration.md +++ b/web/docs/overlay-migration.md @@ -9,9 +9,7 @@ This document tracks the migration away from legacy overlay APIs. - `@/app/components/base/tooltip` - `@/app/components/base/modal` - `@/app/components/base/select` (including `custom` / `pure`) - - `@/app/components/base/dropdown` - `@/app/components/base/dialog` - - `@/app/components/base/toast` (including `context`) - Replacement primitives: - `@/app/components/base/ui/tooltip` - `@/app/components/base/ui/dropdown-menu` @@ -42,12 +40,6 @@ This document tracks the migration away from legacy overlay APIs. - Remove remaining allowlist entries. - Remove legacy overlay implementations when import count reaches zero. -## Toast migration strategy - -- `@/app/components/base/toast` has been replaced by `@/app/components/base/ui/toast`. -- All new toast usage must go through `@/app/components/base/ui/toast`. -- When a file with legacy toast usage is touched, prefer migrating that call site in the same change; full-repo toast cleanup is not required in one PR. - ## Allowlist maintenance - After each migration batch, run: diff --git a/web/eslint.constants.mjs b/web/eslint.constants.mjs index fb912d6524..57a14fc22d 100644 --- a/web/eslint.constants.mjs +++ b/web/eslint.constants.mjs @@ -52,13 +52,6 @@ export const OVERLAY_RESTRICTED_IMPORT_PATTERNS = [ ], message: 'Deprecated: use @/app/components/base/ui/select instead. See issue #32767.', }, - { - group: [ - '**/base/dropdown', - '**/base/dropdown/index', - ], - message: 'Deprecated: use @/app/components/base/ui/dropdown-menu instead. See issue #32767.', - }, { group: [ '**/base/dialog', @@ -80,7 +73,6 @@ export const OVERLAY_MIGRATION_LEGACY_BASE_FILES = [ 'app/components/base/chip/index.tsx', 'app/components/base/date-and-time-picker/date-picker/index.tsx', 'app/components/base/date-and-time-picker/time-picker/index.tsx', - 'app/components/base/dropdown/index.tsx', 'app/components/base/features/new-feature-panel/file-upload/setting-modal.tsx', 'app/components/base/features/new-feature-panel/text-to-speech/voice-settings.tsx', 'app/components/base/file-uploader/file-from-link-or-local/index.tsx', From de15e5b4497a4b4b590cbb454d45afaa349f7182 Mon Sep 17 00:00:00 2001 From: Junghwan <70629228+shaun0927@users.noreply.github.com> Date: Fri, 17 Apr 2026 15:12:07 +0900 Subject: [PATCH 02/24] fix: scope plugin inner API end-user lookup by tenant (#35325) --- api/controllers/inner_api/plugin/wraps.py | 16 ++++- .../inner_api/plugin/test_plugin_wraps.py | 61 ++++++++++++++++--- 2 files changed, 65 insertions(+), 12 deletions(-) diff --git a/api/controllers/inner_api/plugin/wraps.py b/api/controllers/inner_api/plugin/wraps.py index a5846e2815..2f309262cb 100644 --- a/api/controllers/inner_api/plugin/wraps.py +++ b/api/controllers/inner_api/plugin/wraps.py @@ -20,10 +20,13 @@ class TenantUserPayload(BaseModel): def get_user(tenant_id: str, user_id: str | None) -> EndUser: """ - Get current user + Get current user. NOTE: user_id is not trusted, it could be maliciously set to any value. - As a result, it could only be considered as an end user id. + As a result, it could only be considered as an end user id. Even when a + concrete end-user ID is supplied, lookups must stay tenant-scoped so one + tenant cannot bind another tenant's user record into the plugin request + context. """ if not user_id: user_id = DefaultEndUserSessionID.DEFAULT_SESSION_ID @@ -42,7 +45,14 @@ def get_user(tenant_id: str, user_id: str | None) -> EndUser: .limit(1) ) else: - user_model = session.get(EndUser, user_id) + user_model = session.scalar( + select(EndUser) + .where( + EndUser.id == user_id, + EndUser.tenant_id == tenant_id, + ) + .limit(1) + ) if not user_model: user_model = EndUser( diff --git a/api/tests/unit_tests/controllers/inner_api/plugin/test_plugin_wraps.py b/api/tests/unit_tests/controllers/inner_api/plugin/test_plugin_wraps.py index 0895fac3a4..d1b09c3a58 100644 --- a/api/tests/unit_tests/controllers/inner_api/plugin/test_plugin_wraps.py +++ b/api/tests/unit_tests/controllers/inner_api/plugin/test_plugin_wraps.py @@ -41,17 +41,22 @@ class TestTenantUserPayload: class TestGetUser: """Test get_user function""" + @patch("controllers.inner_api.plugin.wraps.select") @patch("controllers.inner_api.plugin.wraps.EndUser") @patch("controllers.inner_api.plugin.wraps.sessionmaker") @patch("controllers.inner_api.plugin.wraps.db") - def test_should_return_existing_user_by_id(self, mock_db, mock_sessionmaker, mock_enduser_class, app: Flask): + def test_should_return_existing_user_by_id( + self, mock_db, mock_sessionmaker, mock_enduser_class, mock_select, app: Flask + ): """Test returning existing user when found by ID""" # Arrange mock_user = MagicMock() mock_user.id = "user123" mock_session = MagicMock() mock_sessionmaker.return_value.begin.return_value.__enter__.return_value = mock_session - mock_session.get.return_value = mock_user + mock_session.scalar.return_value = mock_user + mock_query = MagicMock() + mock_select.return_value.where.return_value.limit.return_value = mock_query # Act with app.app_context(): @@ -59,13 +64,45 @@ class TestGetUser: # Assert assert result == mock_user - mock_session.get.assert_called_once() + mock_session.scalar.assert_called_once() + @patch("controllers.inner_api.plugin.wraps.select") + @patch("controllers.inner_api.plugin.wraps.EndUser") + @patch("controllers.inner_api.plugin.wraps.sessionmaker") + @patch("controllers.inner_api.plugin.wraps.db") + def test_should_not_resolve_non_anonymous_users_across_tenants( + self, + mock_db, + mock_sessionmaker, + mock_enduser_class, + mock_select, + app: Flask, + ): + """Test that explicit user IDs remain scoped to the current tenant.""" + # Arrange + mock_session = MagicMock() + mock_sessionmaker.return_value.begin.return_value.__enter__.return_value = mock_session + mock_session.scalar.return_value = None + mock_new_user = MagicMock() + mock_new_user.tenant_id = "tenant-current" + mock_enduser_class.return_value = mock_new_user + + # Act + with app.app_context(): + result = get_user("tenant-current", "foreign-user-id") + + # Assert + assert result == mock_new_user + mock_session.get.assert_not_called() + mock_session.scalar.assert_called_once() + mock_session.add.assert_called_once_with(mock_new_user) + + @patch("controllers.inner_api.plugin.wraps.select") @patch("controllers.inner_api.plugin.wraps.EndUser") @patch("controllers.inner_api.plugin.wraps.sessionmaker") @patch("controllers.inner_api.plugin.wraps.db") def test_should_return_existing_anonymous_user_by_session_id( - self, mock_db, mock_sessionmaker, mock_enduser_class, app: Flask + self, mock_db, mock_sessionmaker, mock_enduser_class, mock_select, app: Flask ): """Test returning existing anonymous user by session_id""" # Arrange @@ -73,8 +110,9 @@ class TestGetUser: mock_user.session_id = "anonymous_session" mock_session = MagicMock() mock_sessionmaker.return_value.begin.return_value.__enter__.return_value = mock_session - # non-anonymous path uses session.get(); anonymous uses session.scalar() - mock_session.get.return_value = mock_user + mock_session.scalar.return_value = mock_user + mock_query = MagicMock() + mock_select.return_value.where.return_value.limit.return_value = mock_query # Act with app.app_context(): @@ -83,17 +121,22 @@ class TestGetUser: # Assert assert result == mock_user + @patch("controllers.inner_api.plugin.wraps.select") @patch("controllers.inner_api.plugin.wraps.EndUser") @patch("controllers.inner_api.plugin.wraps.sessionmaker") @patch("controllers.inner_api.plugin.wraps.db") - def test_should_create_new_user_when_not_found(self, mock_db, mock_sessionmaker, mock_enduser_class, app: Flask): + def test_should_create_new_user_when_not_found( + self, mock_db, mock_sessionmaker, mock_enduser_class, mock_select, app: Flask + ): """Test creating new user when not found in database""" # Arrange mock_session = MagicMock() mock_sessionmaker.return_value.begin.return_value.__enter__.return_value = mock_session - mock_session.get.return_value = None + mock_session.scalar.return_value = None mock_new_user = MagicMock() mock_enduser_class.return_value = mock_new_user + mock_query = MagicMock() + mock_select.return_value.where.return_value.limit.return_value = mock_query # Act with app.app_context(): @@ -134,7 +177,7 @@ class TestGetUser: # Arrange mock_session = MagicMock() mock_sessionmaker.return_value.begin.return_value.__enter__.return_value = mock_session - mock_session.get.side_effect = Exception("Database error") + mock_session.scalar.side_effect = Exception("Database error") # Act & Assert with app.app_context(): From f5e9b02565c4b8165cdb70074f0b773de8f0b47b Mon Sep 17 00:00:00 2001 From: Jingyi Date: Thu, 16 Apr 2026 23:31:54 -0700 Subject: [PATCH 03/24] test: add API seeding infrastructure and app creation E2E scenarios (#35276) Co-authored-by: Claude Sonnet 4.6 --- e2e/features/apps/create-agent-app.feature | 11 ++++ e2e/features/apps/create-chatflow-app.feature | 10 ++++ .../apps/create-text-generator-app.feature | 11 ++++ e2e/features/apps/delete-app.feature | 11 ++++ e2e/features/apps/duplicate-app.feature | 10 ++++ e2e/features/apps/export-app.feature | 9 ++++ e2e/features/apps/switch-app-mode.feature | 10 ++++ .../step-definitions/apps/create-app.steps.ts | 13 +++-- .../step-definitions/apps/delete-app.steps.ts | 35 ++++++++++++ .../apps/duplicate-app.steps.ts | 36 +++++++++++++ .../step-definitions/apps/export-app.steps.ts | 19 +++++++ .../apps/switch-app-mode.steps.ts | 28 ++++++++++ e2e/features/support/hooks.ts | 3 ++ e2e/features/support/world.ts | 17 +++--- e2e/support/api.ts | 54 +++++++++++++++++++ e2e/vite.config.ts | 4 +- 16 files changed, 268 insertions(+), 13 deletions(-) create mode 100644 e2e/features/apps/create-agent-app.feature create mode 100644 e2e/features/apps/create-chatflow-app.feature create mode 100644 e2e/features/apps/create-text-generator-app.feature create mode 100644 e2e/features/apps/delete-app.feature create mode 100644 e2e/features/apps/duplicate-app.feature create mode 100644 e2e/features/apps/export-app.feature create mode 100644 e2e/features/apps/switch-app-mode.feature create mode 100644 e2e/features/step-definitions/apps/delete-app.steps.ts create mode 100644 e2e/features/step-definitions/apps/duplicate-app.steps.ts create mode 100644 e2e/features/step-definitions/apps/export-app.steps.ts create mode 100644 e2e/features/step-definitions/apps/switch-app-mode.steps.ts create mode 100644 e2e/support/api.ts diff --git a/e2e/features/apps/create-agent-app.feature b/e2e/features/apps/create-agent-app.feature new file mode 100644 index 0000000000..75d8dc3a77 --- /dev/null +++ b/e2e/features/apps/create-agent-app.feature @@ -0,0 +1,11 @@ +@apps @authenticated @core @mode-matrix +Feature: Create Agent app + Scenario: Create a new Agent app and redirect to the configuration page + Given I am signed in as the default E2E admin + When I open the apps console + And I start creating a blank app + And I expand the beginner app types + And I select the "Agent" app type + And I enter a unique E2E app name + And I confirm app creation + Then I should land on the app configuration page diff --git a/e2e/features/apps/create-chatflow-app.feature b/e2e/features/apps/create-chatflow-app.feature new file mode 100644 index 0000000000..364cccc494 --- /dev/null +++ b/e2e/features/apps/create-chatflow-app.feature @@ -0,0 +1,10 @@ +@apps @authenticated @core @mode-matrix +Feature: Create Chatflow app + Scenario: Create a new Chatflow app and redirect to the workflow editor + Given I am signed in as the default E2E admin + When I open the apps console + And I start creating a blank app + And I select the "Chatflow" app type + And I enter a unique E2E app name + And I confirm app creation + Then I should land on the workflow editor diff --git a/e2e/features/apps/create-text-generator-app.feature b/e2e/features/apps/create-text-generator-app.feature new file mode 100644 index 0000000000..aec0436644 --- /dev/null +++ b/e2e/features/apps/create-text-generator-app.feature @@ -0,0 +1,11 @@ +@apps @authenticated @core @mode-matrix +Feature: Create Text Generator app + Scenario: Create a new Text Generator app and redirect to the configuration page + Given I am signed in as the default E2E admin + When I open the apps console + And I start creating a blank app + And I expand the beginner app types + And I select the "Text Generator" app type + And I enter a unique E2E app name + And I confirm app creation + Then I should land on the app configuration page diff --git a/e2e/features/apps/delete-app.feature b/e2e/features/apps/delete-app.feature new file mode 100644 index 0000000000..49326ba098 --- /dev/null +++ b/e2e/features/apps/delete-app.feature @@ -0,0 +1,11 @@ +@apps @authenticated @core +Feature: Delete app + Scenario: Delete an existing app from the apps console + Given I am signed in as the default E2E admin + And there is an existing E2E app available for testing + When I open the apps console + And I open the options menu for the last created E2E app + And I click "Delete" in the app options menu + And I type the app name in the deletion confirmation + And I confirm the deletion + Then the app should no longer appear in the apps console diff --git a/e2e/features/apps/duplicate-app.feature b/e2e/features/apps/duplicate-app.feature new file mode 100644 index 0000000000..3645a7d172 --- /dev/null +++ b/e2e/features/apps/duplicate-app.feature @@ -0,0 +1,10 @@ +@apps @authenticated @core +Feature: Duplicate app + Scenario: Duplicate an existing app and open the copy in the editor + Given I am signed in as the default E2E admin + And there is an existing E2E app available for testing + When I open the apps console + And I open the options menu for the last created E2E app + And I click "Duplicate" in the app options menu + And I confirm the app duplication + Then I should land on the app editor diff --git a/e2e/features/apps/export-app.feature b/e2e/features/apps/export-app.feature new file mode 100644 index 0000000000..d6d040fb00 --- /dev/null +++ b/e2e/features/apps/export-app.feature @@ -0,0 +1,9 @@ +@apps @authenticated @core +Feature: Export app DSL + Scenario: Export the DSL file for an existing app + Given I am signed in as the default E2E admin + And there is an existing E2E completion app available for testing + When I open the apps console + And I open the options menu for the last created E2E app + And I click "Export DSL" in the app options menu + Then a YAML file named after the app should be downloaded diff --git a/e2e/features/apps/switch-app-mode.feature b/e2e/features/apps/switch-app-mode.feature new file mode 100644 index 0000000000..5cdc6341fb --- /dev/null +++ b/e2e/features/apps/switch-app-mode.feature @@ -0,0 +1,10 @@ +@apps @authenticated @core +Feature: Switch app mode + Scenario: Switch a Completion app to Workflow Orchestrate + Given I am signed in as the default E2E admin + And there is an existing E2E completion app available for testing + When I open the apps console + And I open the options menu for the last created E2E app + And I click "Switch to Workflow Orchestrate" in the app options menu + And I confirm the app switch + Then I should land on the switched app diff --git a/e2e/features/step-definitions/apps/create-app.steps.ts b/e2e/features/step-definitions/apps/create-app.steps.ts index e444b97dc8..931d4662a2 100644 --- a/e2e/features/step-definitions/apps/create-app.steps.ts +++ b/e2e/features/step-definitions/apps/create-app.steps.ts @@ -11,7 +11,7 @@ When('I start creating a blank app', async function (this: DifyWorld) { When('I enter a unique E2E app name', async function (this: DifyWorld) { const appName = `E2E App ${Date.now()}` - + this.lastCreatedAppName = appName await this.getPage().getByPlaceholder('Give your app a name').fill(appName) }) @@ -26,10 +26,15 @@ When('I confirm app creation', async function (this: DifyWorld) { When('I select the {string} app type', async function (this: DifyWorld, appType: string) { const dialog = this.getPage().getByRole('dialog') - const appTypeTitle = dialog.getByText(appType, { exact: true }) + // The modal defaults to ADVANCED_CHAT, so the preview panel immediately renders + //

Chatflow

alongside the card's
Chatflow
. + // locator('div').getByText(...) would still match the

because getByText + // searches inside each div for any descendant. Use :text-is() instead, which + // targets only
elements whose own normalised text equals appType exactly. + const appTypeCard = dialog.locator(`div:text-is("${appType}")`) - await expect(appTypeTitle).toBeVisible() - await appTypeTitle.click() + await expect(appTypeCard).toBeVisible() + await appTypeCard.click() }) When('I expand the beginner app types', async function (this: DifyWorld) { diff --git a/e2e/features/step-definitions/apps/delete-app.steps.ts b/e2e/features/step-definitions/apps/delete-app.steps.ts new file mode 100644 index 0000000000..e5da626645 --- /dev/null +++ b/e2e/features/step-definitions/apps/delete-app.steps.ts @@ -0,0 +1,35 @@ +import type { DifyWorld } from '../../support/world' +import { Then, When } from '@cucumber/cucumber' +import { expect } from '@playwright/test' + +When('I type the app name in the deletion confirmation', async function (this: DifyWorld) { + const appName = this.lastCreatedAppName + if (!appName) { + throw new Error( + 'No app name stored. Run "there is an existing E2E app available for testing" first.', + ) + } + + const page = this.getPage() + const dialog = page.getByRole('alertdialog') + await expect(dialog).toBeVisible() + await dialog.getByPlaceholder('Enter app name…').fill(appName) +}) + +When('I confirm the deletion', async function (this: DifyWorld) { + const dialog = this.getPage().getByRole('alertdialog') + await dialog.getByRole('button', { name: 'Confirm' }).click() +}) + +Then('the app should no longer appear in the apps console', async function (this: DifyWorld) { + const appName = this.lastCreatedAppName + if (!appName) { + throw new Error( + 'No app name stored. Run "there is an existing E2E app available for testing" first.', + ) + } + + await expect(this.getPage().getByTitle(appName)).not.toBeVisible({ + timeout: 10_000, + }) +}) diff --git a/e2e/features/step-definitions/apps/duplicate-app.steps.ts b/e2e/features/step-definitions/apps/duplicate-app.steps.ts new file mode 100644 index 0000000000..e5e3694e4d --- /dev/null +++ b/e2e/features/step-definitions/apps/duplicate-app.steps.ts @@ -0,0 +1,36 @@ +import type { DifyWorld } from '../../support/world' +import { Given, When } from '@cucumber/cucumber' +import { createTestApp } from '../../../support/api' + +Given('there is an existing E2E app available for testing', async function (this: DifyWorld) { + const name = `E2E Test App ${Date.now()}` + const app = await createTestApp(name, 'completion') + this.lastCreatedAppName = app.name + this.createdAppIds.push(app.id) +}) + +When('I open the options menu for the last created E2E app', async function (this: DifyWorld) { + const appName = this.lastCreatedAppName + if (!appName) + throw new Error('No app name stored. Run "I enter a unique E2E app name" first.') + + const page = this.getPage() + // Scope to the specific card: the card root is the innermost div that contains + // both the unique app name text and a More button (they are in separate branches, + // so no child div satisfies both). .last() picks the deepest match in DOM order. + const appCard = page + .locator('div') + .filter({ has: page.getByText(appName, { exact: true }) }) + .filter({ has: page.getByRole('button', { name: 'More' }) }) + .last() + await appCard.hover() + await appCard.getByRole('button', { name: 'More' }).click() +}) + +When('I click {string} in the app options menu', async function (this: DifyWorld, label: string) { + await this.getPage().getByRole('menuitem', { name: label }).click() +}) + +When('I confirm the app duplication', async function (this: DifyWorld) { + await this.getPage().getByRole('button', { name: 'Duplicate' }).click() +}) diff --git a/e2e/features/step-definitions/apps/export-app.steps.ts b/e2e/features/step-definitions/apps/export-app.steps.ts new file mode 100644 index 0000000000..4ebeecb507 --- /dev/null +++ b/e2e/features/step-definitions/apps/export-app.steps.ts @@ -0,0 +1,19 @@ +import type { DifyWorld } from '../../support/world' +import { Then } from '@cucumber/cucumber' +import { expect } from '@playwright/test' + +Then('a YAML file named after the app should be downloaded', async function (this: DifyWorld) { + const appName = this.lastCreatedAppName + if (!appName) { + throw new Error( + 'No app name stored. Run "there is an existing E2E app available for testing" first.', + ) + } + + // The export triggers an async API call before the blob download fires. + // Poll until the download event is captured by the page listener in DifyWorld. + await expect.poll(() => this.capturedDownloads.length, { timeout: 10_000 }).toBeGreaterThan(0) + + const download = this.capturedDownloads.at(-1)! + expect(download.suggestedFilename()).toBe(`${appName}.yml`) +}) diff --git a/e2e/features/step-definitions/apps/switch-app-mode.steps.ts b/e2e/features/step-definitions/apps/switch-app-mode.steps.ts new file mode 100644 index 0000000000..55ad1ab02c --- /dev/null +++ b/e2e/features/step-definitions/apps/switch-app-mode.steps.ts @@ -0,0 +1,28 @@ +import type { DifyWorld } from '../../support/world' +import { Given, Then, When } from '@cucumber/cucumber' +import { expect } from '@playwright/test' +import { createTestApp } from '../../../support/api' + +Given( + 'there is an existing E2E completion app available for testing', + async function (this: DifyWorld) { + const name = `E2E Test App ${Date.now()}` + const app = await createTestApp(name, 'completion') + this.lastCreatedAppName = app.name + this.createdAppIds.push(app.id) + }, +) + +When('I confirm the app switch', async function (this: DifyWorld) { + await this.getPage().getByRole('button', { name: 'Start switch' }).click() +}) + +Then('I should land on the switched app', async function (this: DifyWorld) { + const page = this.getPage() + await expect(page).toHaveURL(/\/app\/[^/]+\/workflow(?:\?.*)?$/, { timeout: 15_000 }) + + // Capture the new app's ID so the After hook can clean it up + const match = page.url().match(/\/app\/([^/]+)\/workflow/) + if (match?.[1]) + this.createdAppIds.push(match[1]) +}) diff --git a/e2e/features/support/hooks.ts b/e2e/features/support/hooks.ts index 33b337fb93..c1a535ee2c 100644 --- a/e2e/features/support/hooks.ts +++ b/e2e/features/support/hooks.ts @@ -6,6 +6,7 @@ import { fileURLToPath } from 'node:url' import { After, AfterAll, Before, BeforeAll, setDefaultTimeout, Status } from '@cucumber/cucumber' import { chromium } from '@playwright/test' import { AUTH_BOOTSTRAP_TIMEOUT_MS, ensureAuthenticatedState } from '../../fixtures/auth' +import { deleteTestApp } from '../../support/api' import { baseURL, cucumberHeadless, cucumberSlowMo } from '../../test-env' const e2eRoot = fileURLToPath(new URL('../..', import.meta.url)) @@ -88,6 +89,8 @@ After(async function (this: DifyWorld, { pickle, result }) { `[e2e] end ${pickle.name} status=${status}${elapsedMs ? ` durationMs=${elapsedMs}` : ''}`, ) + for (const id of this.createdAppIds) await deleteTestApp(id).catch(() => {}) + await this.closeSession() }) diff --git a/e2e/features/support/world.ts b/e2e/features/support/world.ts index 0e9c4b9c84..986f79c8f9 100644 --- a/e2e/features/support/world.ts +++ b/e2e/features/support/world.ts @@ -1,12 +1,8 @@ import type { IWorldOptions } from '@cucumber/cucumber' -import type { Browser, BrowserContext, ConsoleMessage, Page } from '@playwright/test' +import type { Browser, BrowserContext, ConsoleMessage, Download, Page } from '@playwright/test' import type { AuthSessionMetadata } from '../../fixtures/auth' import { setWorldConstructor, World } from '@cucumber/cucumber' -import { - - authStatePath, - readAuthSessionMetadata, -} from '../../fixtures/auth' +import { authStatePath, readAuthSessionMetadata } from '../../fixtures/auth' import { baseURL, defaultLocale } from '../../test-env' export class DifyWorld extends World { @@ -16,6 +12,9 @@ export class DifyWorld extends World { pageErrors: string[] = [] scenarioStartedAt: number | undefined session: AuthSessionMetadata | undefined + lastCreatedAppName: string | undefined + createdAppIds: string[] = [] + capturedDownloads: Download[] = [] constructor(options: IWorldOptions) { super(options) @@ -25,6 +24,9 @@ export class DifyWorld extends World { resetScenarioState() { this.consoleErrors = [] this.pageErrors = [] + this.lastCreatedAppName = undefined + this.createdAppIds = [] + this.capturedDownloads = [] } async startSession(browser: Browser, authenticated: boolean) { @@ -45,6 +47,9 @@ export class DifyWorld extends World { this.page.on('pageerror', (error) => { this.pageErrors.push(error.message) }) + this.page.on('download', (dl) => { + this.capturedDownloads.push(dl) + }) } async startAuthenticatedSession(browser: Browser) { diff --git a/e2e/support/api.ts b/e2e/support/api.ts new file mode 100644 index 0000000000..c6d6c98bde --- /dev/null +++ b/e2e/support/api.ts @@ -0,0 +1,54 @@ +import { readFile } from 'node:fs/promises' +import { request } from '@playwright/test' +import { authStatePath } from '../fixtures/auth' +import { apiURL } from '../test-env' + +type StorageState = { + cookies: Array<{ name: string, value: string }> +} + +async function createApiContext() { + const state = JSON.parse(await readFile(authStatePath, 'utf8')) as StorageState + const csrfToken = state.cookies.find(c => c.name.endsWith('csrf_token'))?.value ?? '' + + return request.newContext({ + baseURL: apiURL, + extraHTTPHeaders: { 'X-CSRF-Token': csrfToken }, + storageState: authStatePath, + }) +} + +export type AppSeed = { + id: string + name: string +} + +export async function createTestApp(name: string, mode = 'workflow'): Promise { + const ctx = await createApiContext() + try { + const response = await ctx.post('/console/api/apps', { + data: { + name, + mode, + icon_type: 'emoji', + icon: '🤖', + icon_background: '#FFEAD5', + }, + }) + const body = (await response.json()) as AppSeed + return body + } + finally { + await ctx.dispose() + } +} + +export async function deleteTestApp(id: string): Promise { + const ctx = await createApiContext() + try { + await ctx.delete(`/console/api/apps/${id}`) + } + finally { + await ctx.dispose() + } +} diff --git a/e2e/vite.config.ts b/e2e/vite.config.ts index 2329b534b4..f3dd7bbb0b 100644 --- a/e2e/vite.config.ts +++ b/e2e/vite.config.ts @@ -1,5 +1,3 @@ import { defineConfig } from 'vite-plus' -export default defineConfig({ - -}) +export default defineConfig({}) From 90d638fba31da0e8ec0292b14e1cb92416109cdb Mon Sep 17 00:00:00 2001 From: sxxtony <166789813+sxxtony@users.noreply.github.com> Date: Thu, 16 Apr 2026 23:58:32 -0700 Subject: [PATCH 04/24] refactor: migrate DocumentSegmentSummary to TypeBase (#34862) --- api/models/dataset.py | 43 ++++++++++++++++++--------- api/services/summary_index_service.py | 4 ++- 2 files changed, 32 insertions(+), 15 deletions(-) diff --git a/api/models/dataset.py b/api/models/dataset.py index 50301dd2d7..eee5c39a0e 100644 --- a/api/models/dataset.py +++ b/api/models/dataset.py @@ -1715,7 +1715,7 @@ class SegmentAttachmentBinding(TypeBase): ) -class DocumentSegmentSummary(Base): +class DocumentSegmentSummary(TypeBase): __tablename__ = "document_segment_summaries" __table_args__ = ( sa.PrimaryKeyConstraint("id", name="document_segment_summaries_pkey"), @@ -1725,25 +1725,40 @@ class DocumentSegmentSummary(Base): sa.Index("document_segment_summaries_status_idx", "status"), ) - id: Mapped[str] = mapped_column(StringUUID, nullable=False, default=lambda: str(uuid4())) + id: Mapped[str] = mapped_column( + StringUUID, + nullable=False, + insert_default=lambda: str(uuid4()), + default_factory=lambda: str(uuid4()), + init=False, + ) dataset_id: Mapped[str] = mapped_column(StringUUID, nullable=False) document_id: Mapped[str] = mapped_column(StringUUID, nullable=False) # corresponds to DocumentSegment.id or parent chunk id chunk_id: Mapped[str] = mapped_column(StringUUID, nullable=False) - summary_content: Mapped[str] = mapped_column(LongText, nullable=True) - summary_index_node_id: Mapped[str] = mapped_column(String(255), nullable=True) - summary_index_node_hash: Mapped[str] = mapped_column(String(255), nullable=True) - tokens: Mapped[int | None] = mapped_column(sa.Integer, nullable=True) - status: Mapped[str] = mapped_column( - EnumText(SummaryStatus, length=32), nullable=False, server_default=sa.text("'generating'") + summary_content: Mapped[str | None] = mapped_column(LongText, nullable=True, default=None) + summary_index_node_id: Mapped[str | None] = mapped_column(String(255), nullable=True, default=None) + summary_index_node_hash: Mapped[str | None] = mapped_column(String(255), nullable=True, default=None) + tokens: Mapped[int | None] = mapped_column(sa.Integer, nullable=True, default=None) + status: Mapped[SummaryStatus] = mapped_column( + EnumText(SummaryStatus, length=32), + nullable=False, + server_default=sa.text("'generating'"), + default=SummaryStatus.GENERATING, + ) + error: Mapped[str | None] = mapped_column(LongText, nullable=True, default=None) + enabled: Mapped[bool] = mapped_column(sa.Boolean, nullable=False, server_default=sa.text("true"), default=True) + disabled_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, default=None) + disabled_by: Mapped[str | None] = mapped_column(StringUUID, nullable=True, default=None) + created_at: Mapped[datetime] = mapped_column( + DateTime, nullable=False, server_default=func.current_timestamp(), init=False ) - error: Mapped[str] = mapped_column(LongText, nullable=True) - enabled: Mapped[bool] = mapped_column(sa.Boolean, nullable=False, server_default=sa.text("true")) - disabled_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) - disabled_by = mapped_column(StringUUID, nullable=True) - created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default=func.current_timestamp()) updated_at: Mapped[datetime] = mapped_column( - DateTime, nullable=False, server_default=func.current_timestamp(), onupdate=func.current_timestamp() + DateTime, + nullable=False, + server_default=func.current_timestamp(), + onupdate=func.current_timestamp(), + init=False, ) def __repr__(self): diff --git a/api/services/summary_index_service.py b/api/services/summary_index_service.py index a91f49e9e6..cf39469be8 100644 --- a/api/services/summary_index_service.py +++ b/api/services/summary_index_service.py @@ -349,7 +349,6 @@ class SummaryIndexService: summary_record_id, ) summary_record_in_session = DocumentSegmentSummary( - id=summary_record_id, # Use the same ID if available dataset_id=dataset.id, document_id=segment.document_id, chunk_id=segment.id, @@ -360,6 +359,9 @@ class SummaryIndexService: status=SummaryStatus.COMPLETED, enabled=True, ) + if summary_record_in_session is None: + raise RuntimeError("summary_record_in_session should not be None at this point") + summary_record_in_session.id = summary_record_id session.add(summary_record_in_session) logger.info( "Created new summary record (id=%s) for segment %s after vectorization", From 90e281c8da17ccefd2746e4dd6f10a71e9672a53 Mon Sep 17 00:00:00 2001 From: James <63717587+jamesrayammons@users.noreply.github.com> Date: Fri, 17 Apr 2026 09:03:30 +0200 Subject: [PATCH 05/24] test: migrate dataset service document mock tests to testcontainers (#35191) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- .../services/test_dataset_service_document.py | 650 ++++++++++++++++++ .../services/test_dataset_service_document.py | 436 ------------ 2 files changed, 650 insertions(+), 436 deletions(-) create mode 100644 api/tests/test_containers_integration_tests/services/test_dataset_service_document.py diff --git a/api/tests/test_containers_integration_tests/services/test_dataset_service_document.py b/api/tests/test_containers_integration_tests/services/test_dataset_service_document.py new file mode 100644 index 0000000000..2bec703f0c --- /dev/null +++ b/api/tests/test_containers_integration_tests/services/test_dataset_service_document.py @@ -0,0 +1,650 @@ +"""Testcontainers integration tests for SQL-backed DocumentService paths.""" + +import datetime +import json +from unittest.mock import create_autospec, patch +from uuid import uuid4 + +import pytest +from werkzeug.exceptions import Forbidden, NotFound + +from core.rag.index_processor.constant.index_type import IndexStructureType +from extensions.storage.storage_type import StorageType +from models import Account +from models.dataset import Dataset, Document +from models.enums import CreatorUserRole, DataSourceType, DocumentCreatedFrom, IndexingStatus +from models.model import UploadFile +from services.dataset_service import DocumentService +from services.errors.account import NoPermissionError + +FIXED_UPLOAD_CREATED_AT = datetime.datetime(2024, 1, 1, 0, 0, 0) + + +class DocumentServiceIntegrationFactory: + @staticmethod + def create_dataset( + db_session_with_containers, + *, + tenant_id: str | None = None, + created_by: str | None = None, + name: str | None = None, + ) -> Dataset: + dataset = Dataset( + tenant_id=tenant_id or str(uuid4()), + name=name or f"dataset-{uuid4()}", + data_source_type=DataSourceType.UPLOAD_FILE, + created_by=created_by or str(uuid4()), + ) + db_session_with_containers.add(dataset) + db_session_with_containers.commit() + return dataset + + @staticmethod + def create_document( + db_session_with_containers, + *, + dataset: Dataset, + name: str = "doc.txt", + position: int = 1, + tenant_id: str | None = None, + indexing_status: str = IndexingStatus.COMPLETED, + enabled: bool = True, + archived: bool = False, + is_paused: bool = False, + need_summary: bool = False, + doc_form: str = IndexStructureType.PARAGRAPH_INDEX, + batch: str | None = None, + data_source_type: str = DataSourceType.UPLOAD_FILE, + data_source_info: dict | None = None, + created_by: str | None = None, + ) -> Document: + document = Document( + tenant_id=tenant_id or dataset.tenant_id, + dataset_id=dataset.id, + position=position, + data_source_type=data_source_type, + data_source_info=json.dumps(data_source_info or {}), + batch=batch or f"batch-{uuid4()}", + name=name, + created_from=DocumentCreatedFrom.WEB, + created_by=created_by or dataset.created_by, + doc_form=doc_form, + ) + document.indexing_status = indexing_status + document.enabled = enabled + document.archived = archived + document.is_paused = is_paused + document.need_summary = need_summary + if indexing_status == IndexingStatus.COMPLETED: + document.completed_at = FIXED_UPLOAD_CREATED_AT + db_session_with_containers.add(document) + db_session_with_containers.commit() + return document + + @staticmethod + def create_upload_file( + db_session_with_containers, + *, + tenant_id: str, + created_by: str, + file_id: str | None = None, + name: str = "source.txt", + ) -> UploadFile: + upload_file = UploadFile( + tenant_id=tenant_id, + storage_type=StorageType.LOCAL, + key=f"uploads/{uuid4()}", + name=name, + size=128, + extension="txt", + mime_type="text/plain", + created_by_role=CreatorUserRole.ACCOUNT, + created_by=created_by, + created_at=FIXED_UPLOAD_CREATED_AT, + used=False, + ) + if file_id: + upload_file.id = file_id + db_session_with_containers.add(upload_file) + db_session_with_containers.commit() + return upload_file + + +@pytest.fixture +def current_user_mock(): + with patch("services.dataset_service.current_user", create_autospec(Account, instance=True)) as current_user: + current_user.id = str(uuid4()) + current_user.current_tenant_id = str(uuid4()) + current_user.current_role = None + yield current_user + + +def test_get_document_returns_none_when_document_id_is_missing(db_session_with_containers): + dataset = DocumentServiceIntegrationFactory.create_dataset(db_session_with_containers) + + assert DocumentService.get_document(dataset.id, None) is None + + +def test_get_document_queries_by_dataset_and_document_id(db_session_with_containers): + dataset = DocumentServiceIntegrationFactory.create_dataset(db_session_with_containers) + document = DocumentServiceIntegrationFactory.create_document(db_session_with_containers, dataset=dataset) + + result = DocumentService.get_document(dataset.id, document.id) + + assert result is not None + assert result.id == document.id + + +def test_get_documents_by_ids_returns_empty_for_empty_input(db_session_with_containers): + dataset = DocumentServiceIntegrationFactory.create_dataset(db_session_with_containers) + + result = DocumentService.get_documents_by_ids(dataset.id, []) + + assert result == [] + + +def test_get_documents_by_ids_uses_single_batch_query(db_session_with_containers): + dataset = DocumentServiceIntegrationFactory.create_dataset(db_session_with_containers) + doc_a = DocumentServiceIntegrationFactory.create_document(db_session_with_containers, dataset=dataset, name="a.txt") + doc_b = DocumentServiceIntegrationFactory.create_document( + db_session_with_containers, + dataset=dataset, + name="b.txt", + position=2, + ) + + result = DocumentService.get_documents_by_ids(dataset.id, [doc_a.id, doc_b.id]) + + assert {document.id for document in result} == {doc_a.id, doc_b.id} + + +def test_update_documents_need_summary_returns_zero_for_empty_input(db_session_with_containers): + dataset = DocumentServiceIntegrationFactory.create_dataset(db_session_with_containers) + + assert DocumentService.update_documents_need_summary(dataset.id, []) == 0 + + +def test_update_documents_need_summary_updates_matching_non_qa_documents(db_session_with_containers): + dataset = DocumentServiceIntegrationFactory.create_dataset(db_session_with_containers) + paragraph_doc = DocumentServiceIntegrationFactory.create_document( + db_session_with_containers, + dataset=dataset, + need_summary=True, + ) + qa_doc = DocumentServiceIntegrationFactory.create_document( + db_session_with_containers, + dataset=dataset, + position=2, + need_summary=True, + doc_form=IndexStructureType.QA_INDEX, + ) + + updated_count = DocumentService.update_documents_need_summary( + dataset.id, + [paragraph_doc.id, qa_doc.id], + need_summary=False, + ) + + db_session_with_containers.expire_all() + refreshed_paragraph = db_session_with_containers.get(Document, paragraph_doc.id) + refreshed_qa = db_session_with_containers.get(Document, qa_doc.id) + assert updated_count == 1 + assert refreshed_paragraph is not None + assert refreshed_qa is not None + assert refreshed_paragraph.need_summary is False + assert refreshed_qa.need_summary is True + + +def test_get_document_download_url_uses_signed_url_helper(db_session_with_containers): + dataset = DocumentServiceIntegrationFactory.create_dataset(db_session_with_containers) + upload_file = DocumentServiceIntegrationFactory.create_upload_file( + db_session_with_containers, + tenant_id=dataset.tenant_id, + created_by=dataset.created_by, + ) + document = DocumentServiceIntegrationFactory.create_document( + db_session_with_containers, + dataset=dataset, + data_source_info={"upload_file_id": upload_file.id}, + ) + + with patch("services.dataset_service.file_helpers.get_signed_file_url", return_value="signed-url") as get_url: + result = DocumentService.get_document_download_url(document) + + assert result == "signed-url" + get_url.assert_called_once_with(upload_file_id=upload_file.id, as_attachment=True) + + +def test_get_upload_file_id_for_upload_file_document_rejects_invalid_source_type(db_session_with_containers): + dataset = DocumentServiceIntegrationFactory.create_dataset(db_session_with_containers) + document = DocumentServiceIntegrationFactory.create_document( + db_session_with_containers, + dataset=dataset, + data_source_type=DataSourceType.WEBSITE_CRAWL, + data_source_info={"url": "https://example.com"}, + ) + + with pytest.raises(NotFound, match="invalid source"): + DocumentService._get_upload_file_id_for_upload_file_document( + document, + invalid_source_message="invalid source", + missing_file_message="missing file", + ) + + +def test_get_upload_file_id_for_upload_file_document_rejects_missing_upload_file_id(db_session_with_containers): + dataset = DocumentServiceIntegrationFactory.create_dataset(db_session_with_containers) + document = DocumentServiceIntegrationFactory.create_document( + db_session_with_containers, + dataset=dataset, + data_source_info={}, + ) + + with pytest.raises(NotFound, match="missing file"): + DocumentService._get_upload_file_id_for_upload_file_document( + document, + invalid_source_message="invalid source", + missing_file_message="missing file", + ) + + +def test_get_upload_file_id_for_upload_file_document_returns_string_id(db_session_with_containers): + dataset = DocumentServiceIntegrationFactory.create_dataset(db_session_with_containers) + document = DocumentServiceIntegrationFactory.create_document( + db_session_with_containers, + dataset=dataset, + data_source_info={"upload_file_id": 99}, + ) + + result = DocumentService._get_upload_file_id_for_upload_file_document( + document, + invalid_source_message="invalid source", + missing_file_message="missing file", + ) + + assert result == "99" + + +def test_get_upload_file_for_upload_file_document_raises_when_file_service_returns_nothing(db_session_with_containers): + dataset = DocumentServiceIntegrationFactory.create_dataset(db_session_with_containers) + document = DocumentServiceIntegrationFactory.create_document( + db_session_with_containers, + dataset=dataset, + data_source_info={"upload_file_id": "missing-file"}, + ) + + with patch("services.dataset_service.FileService.get_upload_files_by_ids", return_value={}): + with pytest.raises(NotFound, match="Uploaded file not found"): + DocumentService._get_upload_file_for_upload_file_document(document) + + +def test_get_upload_file_for_upload_file_document_returns_upload_file(db_session_with_containers): + dataset = DocumentServiceIntegrationFactory.create_dataset(db_session_with_containers) + upload_file = DocumentServiceIntegrationFactory.create_upload_file( + db_session_with_containers, + tenant_id=dataset.tenant_id, + created_by=dataset.created_by, + ) + document = DocumentServiceIntegrationFactory.create_document( + db_session_with_containers, + dataset=dataset, + data_source_info={"upload_file_id": upload_file.id}, + ) + + result = DocumentService._get_upload_file_for_upload_file_document(document) + + assert result.id == upload_file.id + + +def test_get_upload_files_by_document_id_for_zip_download_raises_for_missing_documents(db_session_with_containers): + dataset = DocumentServiceIntegrationFactory.create_dataset(db_session_with_containers) + + with pytest.raises(NotFound, match="Document not found"): + DocumentService._get_upload_files_by_document_id_for_zip_download( + dataset_id=dataset.id, + document_ids=[str(uuid4())], + tenant_id=dataset.tenant_id, + ) + + +def test_get_upload_files_by_document_id_for_zip_download_rejects_cross_tenant_access(db_session_with_containers): + dataset = DocumentServiceIntegrationFactory.create_dataset(db_session_with_containers) + upload_file = DocumentServiceIntegrationFactory.create_upload_file( + db_session_with_containers, + tenant_id=dataset.tenant_id, + created_by=dataset.created_by, + ) + document = DocumentServiceIntegrationFactory.create_document( + db_session_with_containers, + dataset=dataset, + tenant_id=str(uuid4()), + data_source_info={"upload_file_id": upload_file.id}, + ) + + with pytest.raises(Forbidden, match="No permission"): + DocumentService._get_upload_files_by_document_id_for_zip_download( + dataset_id=dataset.id, + document_ids=[document.id], + tenant_id=dataset.tenant_id, + ) + + +def test_get_upload_files_by_document_id_for_zip_download_rejects_missing_upload_files(db_session_with_containers): + dataset = DocumentServiceIntegrationFactory.create_dataset(db_session_with_containers) + document = DocumentServiceIntegrationFactory.create_document( + db_session_with_containers, + dataset=dataset, + data_source_info={"upload_file_id": str(uuid4())}, + ) + + with pytest.raises(NotFound, match="Only uploaded-file documents can be downloaded as ZIP"): + DocumentService._get_upload_files_by_document_id_for_zip_download( + dataset_id=dataset.id, + document_ids=[document.id], + tenant_id=dataset.tenant_id, + ) + + +def test_get_upload_files_by_document_id_for_zip_download_returns_document_keyed_mapping(db_session_with_containers): + dataset = DocumentServiceIntegrationFactory.create_dataset(db_session_with_containers) + upload_file_a = DocumentServiceIntegrationFactory.create_upload_file( + db_session_with_containers, + tenant_id=dataset.tenant_id, + created_by=dataset.created_by, + name="a.txt", + ) + upload_file_b = DocumentServiceIntegrationFactory.create_upload_file( + db_session_with_containers, + tenant_id=dataset.tenant_id, + created_by=dataset.created_by, + name="b.txt", + ) + document_a = DocumentServiceIntegrationFactory.create_document( + db_session_with_containers, + dataset=dataset, + data_source_info={"upload_file_id": upload_file_a.id}, + ) + document_b = DocumentServiceIntegrationFactory.create_document( + db_session_with_containers, + dataset=dataset, + position=2, + data_source_info={"upload_file_id": upload_file_b.id}, + ) + + mapping = DocumentService._get_upload_files_by_document_id_for_zip_download( + dataset_id=dataset.id, + document_ids=[document_a.id, document_b.id], + tenant_id=dataset.tenant_id, + ) + + assert mapping[document_a.id].id == upload_file_a.id + assert mapping[document_b.id].id == upload_file_b.id + + +def test_prepare_document_batch_download_zip_raises_not_found_for_missing_dataset( + current_user_mock, flask_app_with_containers +): + with flask_app_with_containers.app_context(): + with pytest.raises(NotFound, match="Dataset not found"): + DocumentService.prepare_document_batch_download_zip( + dataset_id=str(uuid4()), + document_ids=[str(uuid4())], + tenant_id=current_user_mock.current_tenant_id, + current_user=current_user_mock, + ) + + +def test_prepare_document_batch_download_zip_translates_permission_error_to_forbidden( + db_session_with_containers, + current_user_mock, +): + dataset = DocumentServiceIntegrationFactory.create_dataset( + db_session_with_containers, + tenant_id=current_user_mock.current_tenant_id, + created_by=current_user_mock.id, + ) + + with patch( + "services.dataset_service.DatasetService.check_dataset_permission", + side_effect=NoPermissionError("denied"), + ): + with pytest.raises(Forbidden, match="denied"): + DocumentService.prepare_document_batch_download_zip( + dataset_id=dataset.id, + document_ids=[], + tenant_id=current_user_mock.current_tenant_id, + current_user=current_user_mock, + ) + + +def test_prepare_document_batch_download_zip_returns_upload_files_in_requested_order( + db_session_with_containers, + current_user_mock, +): + dataset = DocumentServiceIntegrationFactory.create_dataset( + db_session_with_containers, + tenant_id=current_user_mock.current_tenant_id, + created_by=current_user_mock.id, + ) + upload_file_a = DocumentServiceIntegrationFactory.create_upload_file( + db_session_with_containers, + tenant_id=dataset.tenant_id, + created_by=dataset.created_by, + name="a.txt", + ) + upload_file_b = DocumentServiceIntegrationFactory.create_upload_file( + db_session_with_containers, + tenant_id=dataset.tenant_id, + created_by=dataset.created_by, + name="b.txt", + ) + document_a = DocumentServiceIntegrationFactory.create_document( + db_session_with_containers, + dataset=dataset, + data_source_info={"upload_file_id": upload_file_a.id}, + ) + document_b = DocumentServiceIntegrationFactory.create_document( + db_session_with_containers, + dataset=dataset, + position=2, + data_source_info={"upload_file_id": upload_file_b.id}, + ) + + upload_files, download_name = DocumentService.prepare_document_batch_download_zip( + dataset_id=dataset.id, + document_ids=[document_b.id, document_a.id], + tenant_id=current_user_mock.current_tenant_id, + current_user=current_user_mock, + ) + + assert [upload_file.id for upload_file in upload_files] == [upload_file_b.id, upload_file_a.id] + assert download_name.endswith(".zip") + + +def test_get_document_by_dataset_id_returns_enabled_documents(db_session_with_containers): + dataset = DocumentServiceIntegrationFactory.create_dataset(db_session_with_containers) + enabled_document = DocumentServiceIntegrationFactory.create_document( + db_session_with_containers, + dataset=dataset, + enabled=True, + ) + DocumentServiceIntegrationFactory.create_document( + db_session_with_containers, + dataset=dataset, + position=2, + enabled=False, + ) + + result = DocumentService.get_document_by_dataset_id(dataset.id) + + assert [document.id for document in result] == [enabled_document.id] + + +def test_get_working_documents_by_dataset_id_returns_completed_enabled_unarchived_documents(db_session_with_containers): + dataset = DocumentServiceIntegrationFactory.create_dataset(db_session_with_containers) + available_document = DocumentServiceIntegrationFactory.create_document( + db_session_with_containers, + dataset=dataset, + indexing_status=IndexingStatus.COMPLETED, + enabled=True, + archived=False, + ) + DocumentServiceIntegrationFactory.create_document( + db_session_with_containers, + dataset=dataset, + position=2, + indexing_status=IndexingStatus.ERROR, + ) + + result = DocumentService.get_working_documents_by_dataset_id(dataset.id) + + assert [document.id for document in result] == [available_document.id] + + +def test_get_error_documents_by_dataset_id_returns_error_and_paused_documents(db_session_with_containers): + dataset = DocumentServiceIntegrationFactory.create_dataset(db_session_with_containers) + error_document = DocumentServiceIntegrationFactory.create_document( + db_session_with_containers, + dataset=dataset, + indexing_status=IndexingStatus.ERROR, + ) + paused_document = DocumentServiceIntegrationFactory.create_document( + db_session_with_containers, + dataset=dataset, + position=2, + indexing_status=IndexingStatus.PAUSED, + ) + DocumentServiceIntegrationFactory.create_document( + db_session_with_containers, + dataset=dataset, + position=3, + indexing_status=IndexingStatus.COMPLETED, + ) + + result = DocumentService.get_error_documents_by_dataset_id(dataset.id) + + assert {document.id for document in result} == {error_document.id, paused_document.id} + + +def test_get_batch_documents_filters_by_current_user_tenant(db_session_with_containers): + dataset = DocumentServiceIntegrationFactory.create_dataset(db_session_with_containers) + batch = f"batch-{uuid4()}" + matching_document = DocumentServiceIntegrationFactory.create_document( + db_session_with_containers, + dataset=dataset, + batch=batch, + ) + DocumentServiceIntegrationFactory.create_document( + db_session_with_containers, + dataset=dataset, + position=2, + tenant_id=str(uuid4()), + batch=batch, + ) + + with patch("services.dataset_service.current_user", create_autospec(Account, instance=True)) as current_user: + current_user.current_tenant_id = dataset.tenant_id + result = DocumentService.get_batch_documents(dataset.id, batch) + + assert [document.id for document in result] == [matching_document.id] + + +def test_get_document_file_detail_returns_upload_file(db_session_with_containers): + dataset = DocumentServiceIntegrationFactory.create_dataset(db_session_with_containers) + upload_file = DocumentServiceIntegrationFactory.create_upload_file( + db_session_with_containers, + tenant_id=dataset.tenant_id, + created_by=dataset.created_by, + ) + + result = DocumentService.get_document_file_detail(upload_file.id) + + assert result is not None + assert result.id == upload_file.id + + +def test_delete_document_emits_signal_and_commits(db_session_with_containers): + dataset = DocumentServiceIntegrationFactory.create_dataset(db_session_with_containers) + upload_file = DocumentServiceIntegrationFactory.create_upload_file( + db_session_with_containers, + tenant_id=dataset.tenant_id, + created_by=dataset.created_by, + ) + document = DocumentServiceIntegrationFactory.create_document( + db_session_with_containers, + dataset=dataset, + data_source_info={"upload_file_id": upload_file.id}, + ) + + with patch("services.dataset_service.document_was_deleted.send") as signal_send: + DocumentService.delete_document(document) + + assert db_session_with_containers.get(Document, document.id) is None + signal_send.assert_called_once_with( + document.id, + dataset_id=document.dataset_id, + doc_form=document.doc_form, + file_id=upload_file.id, + ) + + +def test_delete_documents_ignores_empty_input(db_session_with_containers): + dataset = DocumentServiceIntegrationFactory.create_dataset(db_session_with_containers) + + with patch("services.dataset_service.batch_clean_document_task.delay") as delay: + DocumentService.delete_documents(dataset, []) + + delay.assert_not_called() + + +def test_delete_documents_deletes_rows_and_dispatches_cleanup_task(db_session_with_containers): + dataset = DocumentServiceIntegrationFactory.create_dataset(db_session_with_containers) + dataset.chunk_structure = IndexStructureType.PARAGRAPH_INDEX + db_session_with_containers.commit() + upload_file_a = DocumentServiceIntegrationFactory.create_upload_file( + db_session_with_containers, + tenant_id=dataset.tenant_id, + created_by=dataset.created_by, + name="a.txt", + ) + upload_file_b = DocumentServiceIntegrationFactory.create_upload_file( + db_session_with_containers, + tenant_id=dataset.tenant_id, + created_by=dataset.created_by, + name="b.txt", + ) + document_a = DocumentServiceIntegrationFactory.create_document( + db_session_with_containers, + dataset=dataset, + data_source_info={"upload_file_id": upload_file_a.id}, + ) + document_b = DocumentServiceIntegrationFactory.create_document( + db_session_with_containers, + dataset=dataset, + position=2, + data_source_info={"upload_file_id": upload_file_b.id}, + ) + + with patch("services.dataset_service.batch_clean_document_task.delay") as delay: + DocumentService.delete_documents(dataset, [document_a.id, document_b.id]) + + assert db_session_with_containers.get(Document, document_a.id) is None + assert db_session_with_containers.get(Document, document_b.id) is None + delay.assert_called_once() + args = delay.call_args.args + assert args[0] == [document_a.id, document_b.id] + assert args[1] == dataset.id + assert set(args[3]) == {upload_file_a.id, upload_file_b.id} + + +def test_get_documents_position_returns_next_position_when_documents_exist(db_session_with_containers): + dataset = DocumentServiceIntegrationFactory.create_dataset(db_session_with_containers) + DocumentServiceIntegrationFactory.create_document(db_session_with_containers, dataset=dataset, position=3) + + assert DocumentService.get_documents_position(dataset.id) == 4 + + +def test_get_documents_position_defaults_to_one_when_dataset_is_empty(db_session_with_containers): + dataset = DocumentServiceIntegrationFactory.create_dataset(db_session_with_containers) + + assert DocumentService.get_documents_position(dataset.id) == 1 diff --git a/api/tests/unit_tests/services/test_dataset_service_document.py b/api/tests/unit_tests/services/test_dataset_service_document.py index 3f9386e704..1633194aa8 100644 --- a/api/tests/unit_tests/services/test_dataset_service_document.py +++ b/api/tests/unit_tests/services/test_dataset_service_document.py @@ -12,12 +12,10 @@ from .dataset_service_test_helpers import ( DocumentService, FileInfo, FileNotExistsError, - Forbidden, IndexStructureType, InfoList, KnowledgeConfig, MagicMock, - NoPermissionError, NotFound, NotionIcon, NotionInfo, @@ -35,7 +33,6 @@ from .dataset_service_test_helpers import ( _make_document, _make_features, _make_lock_context, - _make_session_context, _make_upload_knowledge_config, create_autospec, json, @@ -82,366 +79,6 @@ class TestDocumentServiceDisplayStatus: query.where.assert_called_once() -class TestDocumentServiceQueryAndDownloadHelpers: - """Unit tests for DocumentService query helpers and download flows.""" - - def test_get_document_returns_none_when_document_id_is_missing(self): - with patch("services.dataset_service.db") as mock_db: - result = DocumentService.get_document("dataset-1", None) - - assert result is None - mock_db.session.scalar.assert_not_called() - - def test_get_document_queries_by_dataset_and_document_id(self): - document = DatasetServiceUnitDataFactory.create_document_mock() - - with patch("services.dataset_service.db") as mock_db: - mock_db.session.scalar.return_value = document - - result = DocumentService.get_document("dataset-1", "doc-1") - - assert result is document - - def test_get_documents_by_ids_returns_empty_for_empty_input(self): - with patch("services.dataset_service.db") as mock_db: - result = DocumentService.get_documents_by_ids("dataset-1", []) - - assert result == [] - mock_db.session.scalars.assert_not_called() - - def test_get_documents_by_ids_uses_single_batch_query(self): - document = DatasetServiceUnitDataFactory.create_document_mock() - - with patch("services.dataset_service.db") as mock_db: - mock_db.session.scalars.return_value.all.return_value = [document] - - result = DocumentService.get_documents_by_ids("dataset-1", ["doc-1"]) - - assert result == [document] - mock_db.session.scalars.assert_called_once() - - def test_update_documents_need_summary_returns_zero_for_empty_input(self): - with patch("services.dataset_service.session_factory") as session_factory_mock: - result = DocumentService.update_documents_need_summary("dataset-1", []) - - assert result == 0 - session_factory_mock.create_session.assert_not_called() - - def test_update_documents_need_summary_updates_matching_documents_and_commits(self): - session = MagicMock() - session.execute.return_value.rowcount = 2 - - with patch("services.dataset_service.session_factory") as session_factory_mock: - session_factory_mock.create_session.return_value = _make_session_context(session) - - result = DocumentService.update_documents_need_summary( - "dataset-1", - ["doc-1", "doc-2"], - need_summary=False, - ) - - assert result == 2 - session.commit.assert_called_once() - - def test_get_document_download_url_uses_upload_file_lookup_and_signed_url_helper(self): - upload_file = DatasetServiceUnitDataFactory.create_upload_file_mock(file_id="file-1") - document = DatasetServiceUnitDataFactory.create_document_mock() - - with ( - patch.object(DocumentService, "_get_upload_file_for_upload_file_document", return_value=upload_file), - patch("services.dataset_service.file_helpers.get_signed_file_url", return_value="signed-url") as get_url, - ): - result = DocumentService.get_document_download_url(document) - - assert result == "signed-url" - get_url.assert_called_once_with(upload_file_id="file-1", as_attachment=True) - - def test_get_upload_file_id_for_upload_file_document_rejects_invalid_source_type(self): - document = DatasetServiceUnitDataFactory.create_document_mock(data_source_type="not-upload-file") - - with pytest.raises(NotFound, match="invalid source"): - DocumentService._get_upload_file_id_for_upload_file_document( - document, - invalid_source_message="invalid source", - missing_file_message="missing file", - ) - - def test_get_upload_file_id_for_upload_file_document_rejects_missing_upload_file_id(self): - document = DatasetServiceUnitDataFactory.create_document_mock(data_source_info_dict={}) - - with pytest.raises(NotFound, match="missing file"): - DocumentService._get_upload_file_id_for_upload_file_document( - document, - invalid_source_message="invalid source", - missing_file_message="missing file", - ) - - def test_get_upload_file_id_for_upload_file_document_returns_string_id(self): - document = DatasetServiceUnitDataFactory.create_document_mock(data_source_info_dict={"upload_file_id": 99}) - - result = DocumentService._get_upload_file_id_for_upload_file_document( - document, - invalid_source_message="invalid source", - missing_file_message="missing file", - ) - - assert result == "99" - - def test_get_upload_file_for_upload_file_document_raises_when_file_service_returns_nothing(self): - document = DatasetServiceUnitDataFactory.create_document_mock( - tenant_id="tenant-1", - data_source_info_dict={"upload_file_id": "file-1"}, - ) - - with patch("services.dataset_service.FileService.get_upload_files_by_ids", return_value={}): - with pytest.raises(NotFound, match="Uploaded file not found"): - DocumentService._get_upload_file_for_upload_file_document(document) - - def test_get_upload_file_for_upload_file_document_returns_upload_file(self): - document = DatasetServiceUnitDataFactory.create_document_mock( - tenant_id="tenant-1", - data_source_info_dict={"upload_file_id": "file-1"}, - ) - upload_file = DatasetServiceUnitDataFactory.create_upload_file_mock(file_id="file-1") - - with patch( - "services.dataset_service.FileService.get_upload_files_by_ids", return_value={"file-1": upload_file} - ): - result = DocumentService._get_upload_file_for_upload_file_document(document) - - assert result is upload_file - - def test_enrich_documents_with_summary_index_status_skips_lookup_when_summary_is_disabled(self): - dataset = DatasetServiceUnitDataFactory.create_dataset_mock(summary_index_setting={"enable": False}) - documents = [ - DatasetServiceUnitDataFactory.create_document_mock(document_id="doc-1", need_summary=True), - DatasetServiceUnitDataFactory.create_document_mock(document_id="doc-2", need_summary=False), - ] - - DocumentService.enrich_documents_with_summary_index_status(documents, dataset, tenant_id="tenant-1") - - assert documents[0].summary_index_status is None - assert documents[1].summary_index_status is None - - def test_enrich_documents_with_summary_index_status_applies_summary_status_map(self): - dataset = DatasetServiceUnitDataFactory.create_dataset_mock( - dataset_id="dataset-1", - summary_index_setting={"enable": True}, - ) - documents = [ - DatasetServiceUnitDataFactory.create_document_mock(document_id="doc-1", need_summary=True), - DatasetServiceUnitDataFactory.create_document_mock(document_id="doc-2", need_summary=True), - DatasetServiceUnitDataFactory.create_document_mock(document_id="doc-3", need_summary=False), - ] - - with patch( - "services.summary_index_service.SummaryIndexService.get_documents_summary_index_status", - return_value={"doc-1": "completed", "doc-2": None}, - ) as get_status_map: - DocumentService.enrich_documents_with_summary_index_status(documents, dataset, tenant_id="tenant-1") - - get_status_map.assert_called_once_with( - document_ids=["doc-1", "doc-2"], - dataset_id="dataset-1", - tenant_id="tenant-1", - ) - assert documents[0].summary_index_status == "completed" - assert documents[1].summary_index_status is None - assert documents[2].summary_index_status is None - - def test_generate_document_batch_download_zip_filename_uses_zip_extension(self): - fake_uuid = SimpleNamespace(hex="archive-id") - - with patch("services.dataset_service.uuid.uuid4", return_value=fake_uuid): - result = DocumentService._generate_document_batch_download_zip_filename() - - assert result == "archive-id.zip" - - def test_get_upload_files_by_document_id_for_zip_download_raises_for_missing_documents(self): - with patch.object(DocumentService, "get_documents_by_ids", return_value=[]): - with pytest.raises(NotFound, match="Document not found"): - DocumentService._get_upload_files_by_document_id_for_zip_download( - dataset_id="dataset-1", - document_ids=["doc-1"], - tenant_id="tenant-1", - ) - - def test_get_upload_files_by_document_id_for_zip_download_rejects_cross_tenant_access(self): - document = DatasetServiceUnitDataFactory.create_document_mock( - document_id="doc-1", - tenant_id="tenant-other", - data_source_info_dict={"upload_file_id": "file-1"}, - ) - - with patch.object(DocumentService, "get_documents_by_ids", return_value=[document]): - with pytest.raises(Forbidden, match="No permission"): - DocumentService._get_upload_files_by_document_id_for_zip_download( - dataset_id="dataset-1", - document_ids=["doc-1"], - tenant_id="tenant-1", - ) - - def test_get_upload_files_by_document_id_for_zip_download_rejects_missing_upload_files(self): - document = DatasetServiceUnitDataFactory.create_document_mock( - document_id="doc-1", - tenant_id="tenant-1", - data_source_info_dict={"upload_file_id": "file-1"}, - ) - - with ( - patch.object(DocumentService, "get_documents_by_ids", return_value=[document]), - patch("services.dataset_service.FileService.get_upload_files_by_ids", return_value={}), - ): - with pytest.raises(NotFound, match="Only uploaded-file documents can be downloaded as ZIP"): - DocumentService._get_upload_files_by_document_id_for_zip_download( - dataset_id="dataset-1", - document_ids=["doc-1"], - tenant_id="tenant-1", - ) - - def test_get_upload_files_by_document_id_for_zip_download_returns_document_keyed_mapping(self): - document_a = DatasetServiceUnitDataFactory.create_document_mock( - document_id="doc-1", - tenant_id="tenant-1", - data_source_info_dict={"upload_file_id": "file-1"}, - ) - document_b = DatasetServiceUnitDataFactory.create_document_mock( - document_id="doc-2", - tenant_id="tenant-1", - data_source_info_dict={"upload_file_id": "file-2"}, - ) - upload_file_a = DatasetServiceUnitDataFactory.create_upload_file_mock(file_id="file-1") - upload_file_b = DatasetServiceUnitDataFactory.create_upload_file_mock(file_id="file-2") - - with ( - patch.object(DocumentService, "get_documents_by_ids", return_value=[document_a, document_b]), - patch( - "services.dataset_service.FileService.get_upload_files_by_ids", - return_value={"file-1": upload_file_a, "file-2": upload_file_b}, - ), - ): - result = DocumentService._get_upload_files_by_document_id_for_zip_download( - dataset_id="dataset-1", - document_ids=["doc-1", "doc-2"], - tenant_id="tenant-1", - ) - - assert result == {"doc-1": upload_file_a, "doc-2": upload_file_b} - - def test_prepare_document_batch_download_zip_raises_not_found_for_missing_dataset(self): - user = DatasetServiceUnitDataFactory.create_user_mock() - - with patch.object(DatasetService, "get_dataset", return_value=None): - with pytest.raises(NotFound, match="Dataset not found"): - DocumentService.prepare_document_batch_download_zip( - dataset_id="dataset-1", - document_ids=["doc-1"], - tenant_id="tenant-1", - current_user=user, - ) - - def test_prepare_document_batch_download_zip_translates_permission_error_to_forbidden(self): - dataset = DatasetServiceUnitDataFactory.create_dataset_mock() - user = DatasetServiceUnitDataFactory.create_user_mock() - - with ( - patch.object(DatasetService, "get_dataset", return_value=dataset), - patch.object(DatasetService, "check_dataset_permission", side_effect=NoPermissionError("blocked")), - ): - with pytest.raises(Forbidden, match="blocked"): - DocumentService.prepare_document_batch_download_zip( - dataset_id=dataset.id, - document_ids=["doc-1"], - tenant_id="tenant-1", - current_user=user, - ) - - def test_prepare_document_batch_download_zip_returns_upload_files_in_requested_order(self): - dataset = DatasetServiceUnitDataFactory.create_dataset_mock() - user = DatasetServiceUnitDataFactory.create_user_mock() - upload_file_a = DatasetServiceUnitDataFactory.create_upload_file_mock(file_id="file-a") - upload_file_b = DatasetServiceUnitDataFactory.create_upload_file_mock(file_id="file-b") - - with ( - patch.object(DatasetService, "get_dataset", return_value=dataset), - patch.object(DatasetService, "check_dataset_permission"), - patch.object( - DocumentService, - "_get_upload_files_by_document_id_for_zip_download", - return_value={"doc-1": upload_file_a, "doc-2": upload_file_b}, - ), - patch.object(DocumentService, "_generate_document_batch_download_zip_filename", return_value="archive.zip"), - ): - upload_files, download_name = DocumentService.prepare_document_batch_download_zip( - dataset_id=dataset.id, - document_ids=["doc-2", "doc-1"], - tenant_id="tenant-1", - current_user=user, - ) - - assert upload_files == [upload_file_b, upload_file_a] - assert download_name == "archive.zip" - - def test_get_document_by_dataset_id_returns_enabled_documents(self): - document = DatasetServiceUnitDataFactory.create_document_mock(enabled=True) - - with patch("services.dataset_service.db") as mock_db: - mock_db.session.scalars.return_value.all.return_value = [document] - - result = DocumentService.get_document_by_dataset_id("dataset-1") - - assert result == [document] - - def test_get_working_documents_by_dataset_id_returns_scalars_result(self): - document = DatasetServiceUnitDataFactory.create_document_mock(indexing_status="completed", archived=False) - - with patch("services.dataset_service.db") as mock_db: - mock_db.session.scalars.return_value.all.return_value = [document] - - result = DocumentService.get_working_documents_by_dataset_id("dataset-1") - - assert result == [document] - - def test_get_error_documents_by_dataset_id_returns_scalars_result(self): - document = DatasetServiceUnitDataFactory.create_document_mock(indexing_status="error") - - with patch("services.dataset_service.db") as mock_db: - mock_db.session.scalars.return_value.all.return_value = [document] - - result = DocumentService.get_error_documents_by_dataset_id("dataset-1") - - assert result == [document] - - def test_get_batch_documents_filters_by_current_user_tenant(self): - class FakeAccount: - pass - - current_user = FakeAccount() - current_user.current_tenant_id = "tenant-1" - document = DatasetServiceUnitDataFactory.create_document_mock() - - with ( - patch("services.dataset_service.Account", FakeAccount), - patch("services.dataset_service.current_user", current_user), - patch("services.dataset_service.db") as mock_db, - ): - mock_db.session.scalars.return_value.all.return_value = [document] - - result = DocumentService.get_batch_documents("dataset-1", "batch-1") - - assert result == [document] - - def test_get_document_file_detail_returns_one_or_none(self): - upload_file = DatasetServiceUnitDataFactory.create_upload_file_mock() - - with patch("services.dataset_service.db") as mock_db: - mock_db.session.get.return_value = upload_file - - result = DocumentService.get_document_file_detail(upload_file.id) - - assert result is upload_file - - class TestDocumentServiceMutations: """Unit tests for DocumentService mutation and orchestration helpers.""" @@ -466,61 +103,6 @@ class TestDocumentServiceMutations: assert DocumentService.check_archived(document) is expected - def test_delete_document_emits_signal_and_commits(self): - document = DatasetServiceUnitDataFactory.create_document_mock( - data_source_type="upload_file", - data_source_info='{"upload_file_id": "file-1"}', - data_source_info_dict={"upload_file_id": "file-1"}, - ) - - with ( - patch("services.dataset_service.document_was_deleted.send") as send_deleted_signal, - patch("services.dataset_service.db") as mock_db, - ): - DocumentService.delete_document(document) - - send_deleted_signal.assert_called_once_with( - document.id, - dataset_id=document.dataset_id, - doc_form=document.doc_form, - file_id="file-1", - ) - mock_db.session.delete.assert_called_once_with(document) - mock_db.session.commit.assert_called_once() - - def test_delete_documents_ignores_empty_input(self): - dataset = DatasetServiceUnitDataFactory.create_dataset_mock() - - with patch("services.dataset_service.db") as mock_db: - DocumentService.delete_documents(dataset, []) - - mock_db.session.scalars.assert_not_called() - - def test_delete_documents_deletes_rows_and_dispatches_cleanup_task(self): - dataset = DatasetServiceUnitDataFactory.create_dataset_mock(doc_form="text_model") - document_a = DatasetServiceUnitDataFactory.create_document_mock( - document_id="doc-1", - data_source_type="upload_file", - data_source_info_dict={"upload_file_id": "file-1"}, - ) - document_b = DatasetServiceUnitDataFactory.create_document_mock( - document_id="doc-2", - data_source_type="upload_file", - data_source_info_dict={"upload_file_id": "file-2"}, - ) - - with ( - patch("services.dataset_service.db") as mock_db, - patch("services.dataset_service.batch_clean_document_task") as clean_task, - ): - mock_db.session.scalars.return_value.all.return_value = [document_a, document_b] - - DocumentService.delete_documents(dataset, ["doc-1", "doc-2"]) - - assert mock_db.session.delete.call_count == 2 - mock_db.session.commit.assert_called_once() - clean_task.delay.assert_called_once_with(["doc-1", "doc-2"], dataset.id, dataset.doc_form, ["file-1", "file-2"]) - def test_rename_document_raises_when_dataset_is_missing(self, rename_account_context): with patch.object(DatasetService, "get_dataset", return_value=None): with pytest.raises(ValueError, match="Dataset not found"): @@ -620,24 +202,6 @@ class TestDocumentServiceMutations: mock_redis.setex.assert_called_once_with("document_doc-1_is_sync", 600, 1) sync_task.delay.assert_called_once_with("dataset-1", "doc-1") - def test_get_documents_position_returns_next_position_when_documents_exist(self): - document = DatasetServiceUnitDataFactory.create_document_mock(position=7) - - with patch("services.dataset_service.db") as mock_db: - mock_db.session.scalar.return_value = document - - result = DocumentService.get_documents_position("dataset-1") - - assert result == 8 - - def test_get_documents_position_defaults_to_one_when_dataset_is_empty(self): - with patch("services.dataset_service.db") as mock_db: - mock_db.session.scalar.return_value = None - - result = DocumentService.get_documents_position("dataset-1") - - assert result == 1 - class TestDocumentServiceSaveDocumentWithoutDatasetId: """Unit tests for dataset creation around save_document_without_dataset_id.""" From 0c41d0bf51e65c0908050616e0ad51dc89ea168d Mon Sep 17 00:00:00 2001 From: Cocoon-Break <54054995+kuishou68@users.noreply.github.com> Date: Fri, 17 Apr 2026 15:09:40 +0800 Subject: [PATCH 06/24] fix: guard against KeyError in update_prompt_message_tool loop (#35150) Co-authored-by: Crazywoola <100913391+crazywoola@users.noreply.github.com> --- api/core/agent/fc_agent_runner.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/api/core/agent/fc_agent_runner.py b/api/core/agent/fc_agent_runner.py index d38d24d1e7..29de0b8b1c 100644 --- a/api/core/agent/fc_agent_runner.py +++ b/api/core/agent/fc_agent_runner.py @@ -299,7 +299,9 @@ class FunctionCallAgentRunner(BaseAgentRunner): # update prompt tool for prompt_tool in prompt_messages_tools: - self.update_prompt_message_tool(tool_instances[prompt_tool.name], prompt_tool) + tool_instance = tool_instances.get(prompt_tool.name) + if tool_instance: + self.update_prompt_message_tool(tool_instance, prompt_tool) iteration_step += 1 From eaddd4a132dee27d5277a3b8160b3edf7eda8b2f Mon Sep 17 00:00:00 2001 From: Coding On Star <447357187@qq.com> Date: Fri, 17 Apr 2026 15:27:22 +0800 Subject: [PATCH 07/24] fix(web): stabilize workflow node panel operator dropdown trigger (#35352) Signed-off-by: dependabot[bot] Co-authored-by: CodingOnStar Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: yyh Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: jerryzai Co-authored-by: NVIDIAN Co-authored-by: ai-hpc Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com> Co-authored-by: Asuka Minato Co-authored-by: Junghwan <70629228+shaun0927@users.noreply.github.com> Co-authored-by: HeYinKazune <70251095+HeYin-OS@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- eslint-suppressions.json | 5 ---- .../nodes/_base/components/node-control.tsx | 21 ++++++++++------ .../_base/components/panel-operator/index.tsx | 24 ++++++++++--------- 3 files changed, 27 insertions(+), 23 deletions(-) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 763d94af9a..173a3a7bd7 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -4679,11 +4679,6 @@ "count": 1 } }, - "web/app/components/workflow/nodes/_base/components/node-control.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/workflow/nodes/_base/components/node-handle.tsx": { "react/set-state-in-effect": { "count": 1 diff --git a/web/app/components/workflow/nodes/_base/components/node-control.tsx b/web/app/components/workflow/nodes/_base/components/node-control.tsx index aab4b5065d..547d0b1daa 100644 --- a/web/app/components/workflow/nodes/_base/components/node-control.tsx +++ b/web/app/components/workflow/nodes/_base/components/node-control.tsx @@ -9,7 +9,11 @@ import { useTranslation } from 'react-i18next' import { Stop, } from '@/app/components/base/icons/src/vender/line/mediaAndDevices' -import Tooltip from '@/app/components/base/tooltip' +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/app/components/base/ui/tooltip' import { useWorkflowStore } from '@/app/components/workflow/store' import { useNodesInteractions, @@ -46,7 +50,8 @@ const NodeControl: FC = ({ `} >
e.stopPropagation()} onClick={e => e.stopPropagation()} > { @@ -71,11 +76,13 @@ const NodeControl: FC = ({ isSingleRunning ? : ( - - + + } + /> + + {t('panel.runThisStep', { ns: 'workflow' })} + ) } diff --git a/web/app/components/workflow/nodes/_base/components/panel-operator/index.tsx b/web/app/components/workflow/nodes/_base/components/panel-operator/index.tsx index 2109365d75..7b3469aaba 100644 --- a/web/app/components/workflow/nodes/_base/components/panel-operator/index.tsx +++ b/web/app/components/workflow/nodes/_base/components/panel-operator/index.tsx @@ -1,10 +1,12 @@ import type { OffsetOptions } from '@floating-ui/react' import type { Node } from '@/app/components/workflow/types' +import { cn } from '@langgenius/dify-ui/cn' import { memo, useCallback, useState, } from 'react' +import { useTranslation } from 'react-i18next' import { DropdownMenu, DropdownMenuContent, @@ -32,6 +34,7 @@ const PanelOperator = ({ onOpenChange, showHelpLink = true, }: PanelOperatorProps) => { + const { t } = useTranslation() const [open, setOpen] = useState(false) const sideOffset = typeof offset === 'number' ? offset @@ -54,17 +57,16 @@ const PanelOperator = ({ open={open} onOpenChange={handleOpenChange} > - }> -
- -
+ } + aria-label={t('operation.more', { ns: 'common' })} + className={cn( + 'nodrag nopan nowheel flex h-6 w-6 cursor-pointer items-center justify-center rounded-md hover:bg-state-base-hover', + open && 'bg-state-base-hover', + triggerClassName, + )} + > + Date: Fri, 17 Apr 2026 15:42:29 +0800 Subject: [PATCH 08/24] =?UTF-8?q?fix:=20move=20remote=20credential=20valid?= =?UTF-8?q?ation=20outside=20DB=20session=20to=20prevent=20=E2=80=A6=20(#3?= =?UTF-8?q?5350)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- api/core/entities/provider_configuration.py | 177 ++++++++---------- .../test_entities_provider_configuration.py | 156 +++++++-------- 2 files changed, 156 insertions(+), 177 deletions(-) diff --git a/api/core/entities/provider_configuration.py b/api/core/entities/provider_configuration.py index 1ab66cceee..6bbf163c9d 100644 --- a/api/core/entities/provider_configuration.py +++ b/api/core/entities/provider_configuration.py @@ -318,34 +318,28 @@ class ProviderConfiguration(BaseModel): else [], ) - def validate_provider_credentials( - self, credentials: dict[str, Any], credential_id: str = "", session: Session | None = None - ): + def validate_provider_credentials(self, credentials: dict[str, Any], credential_id: str = ""): """ Validate custom credentials. :param credentials: provider credentials :param credential_id: (Optional)If provided, can use existing credential's hidden api key to validate - :param session: optional database session :return: """ + provider_credential_secret_variables = self.extract_secret_variables( + self.provider.provider_credential_schema.credential_form_schemas + if self.provider.provider_credential_schema + else [] + ) - def _validate(s: Session): - # Get provider credential secret variables - provider_credential_secret_variables = self.extract_secret_variables( - self.provider.provider_credential_schema.credential_form_schemas - if self.provider.provider_credential_schema - else [] - ) - - if credential_id: + if credential_id: + with Session(db.engine) as session: try: stmt = select(ProviderCredential).where( ProviderCredential.tenant_id == self.tenant_id, ProviderCredential.provider_name.in_(self._get_provider_names()), ProviderCredential.id == credential_id, ) - credential_record = s.execute(stmt).scalar_one_or_none() - # fix origin data + credential_record = session.execute(stmt).scalar_one_or_none() if credential_record and credential_record.encrypted_config: if not credential_record.encrypted_config.startswith("{"): original_credentials = {"openai_api_key": credential_record.encrypted_config} @@ -356,31 +350,23 @@ class ProviderConfiguration(BaseModel): except JSONDecodeError: original_credentials = {} - # encrypt credentials - for key, value in credentials.items(): - if key in provider_credential_secret_variables: - # if send [__HIDDEN__] in secret input, it will be same as original value - if value == HIDDEN_VALUE and key in original_credentials: - credentials[key] = encrypter.decrypt_token( - tenant_id=self.tenant_id, token=original_credentials[key] - ) - - model_provider_factory = self.get_model_provider_factory() - validated_credentials = model_provider_factory.provider_credentials_validate( - provider=self.provider.provider, credentials=credentials - ) - - for key, value in validated_credentials.items(): + for key, value in credentials.items(): if key in provider_credential_secret_variables: - validated_credentials[key] = encrypter.encrypt_token(self.tenant_id, value) + if value == HIDDEN_VALUE and key in original_credentials: + credentials[key] = encrypter.decrypt_token( + tenant_id=self.tenant_id, token=original_credentials[key] + ) - return validated_credentials + model_provider_factory = self.get_model_provider_factory() + validated_credentials = model_provider_factory.provider_credentials_validate( + provider=self.provider.provider, credentials=credentials + ) - if session: - return _validate(session) - else: - with Session(db.engine) as new_session: - return _validate(new_session) + for key, value in validated_credentials.items(): + if key in provider_credential_secret_variables: + validated_credentials[key] = encrypter.encrypt_token(self.tenant_id, value) + + return validated_credentials def _generate_provider_credential_name(self, session) -> str: """ @@ -457,14 +443,16 @@ class ProviderConfiguration(BaseModel): :param credential_name: credential name :return: """ - with Session(db.engine) as session: + with Session(db.engine) as pre_session: if credential_name: - if self._check_provider_credential_name_exists(credential_name=credential_name, session=session): + if self._check_provider_credential_name_exists(credential_name=credential_name, session=pre_session): raise ValueError(f"Credential with name '{credential_name}' already exists.") else: - credential_name = self._generate_provider_credential_name(session) + credential_name = self._generate_provider_credential_name(pre_session) - credentials = self.validate_provider_credentials(credentials=credentials, session=session) + credentials = self.validate_provider_credentials(credentials=credentials) + + with Session(db.engine) as session: provider_record = self._get_provider_record(session) try: new_record = ProviderCredential( @@ -477,7 +465,6 @@ class ProviderConfiguration(BaseModel): session.flush() if not provider_record: - # If provider record does not exist, create it provider_record = Provider( tenant_id=self.tenant_id, provider_name=self.provider.provider, @@ -530,15 +517,15 @@ class ProviderConfiguration(BaseModel): :param credential_name: credential name :return: """ - with Session(db.engine) as session: + with Session(db.engine) as pre_session: if credential_name and self._check_provider_credential_name_exists( - credential_name=credential_name, session=session, exclude_id=credential_id + credential_name=credential_name, session=pre_session, exclude_id=credential_id ): raise ValueError(f"Credential with name '{credential_name}' already exists.") - credentials = self.validate_provider_credentials( - credentials=credentials, credential_id=credential_id, session=session - ) + credentials = self.validate_provider_credentials(credentials=credentials, credential_id=credential_id) + + with Session(db.engine) as session: provider_record = self._get_provider_record(session) stmt = select(ProviderCredential).where( ProviderCredential.id == credential_id, @@ -546,12 +533,10 @@ class ProviderConfiguration(BaseModel): ProviderCredential.provider_name.in_(self._get_provider_names()), ) - # Get the credential record to update credential_record = session.execute(stmt).scalar_one_or_none() if not credential_record: raise ValueError("Credential record not found.") try: - # Update credential credential_record.encrypted_config = json.dumps(credentials) credential_record.updated_at = naive_utc_now() if credential_name: @@ -879,7 +864,6 @@ class ProviderConfiguration(BaseModel): model: str, credentials: dict[str, Any], credential_id: str = "", - session: Session | None = None, ): """ Validate custom model credentials. @@ -890,16 +874,14 @@ class ProviderConfiguration(BaseModel): :param credential_id: (Optional)If provided, can use existing credential's hidden api key to validate :return: """ + provider_credential_secret_variables = self.extract_secret_variables( + self.provider.model_credential_schema.credential_form_schemas + if self.provider.model_credential_schema + else [] + ) - def _validate(s: Session): - # Get provider credential secret variables - provider_credential_secret_variables = self.extract_secret_variables( - self.provider.model_credential_schema.credential_form_schemas - if self.provider.model_credential_schema - else [] - ) - - if credential_id: + if credential_id: + with Session(db.engine) as session: try: stmt = select(ProviderModelCredential).where( ProviderModelCredential.id == credential_id, @@ -908,7 +890,7 @@ class ProviderConfiguration(BaseModel): ProviderModelCredential.model_name == model, ProviderModelCredential.model_type == model_type, ) - credential_record = s.execute(stmt).scalar_one_or_none() + credential_record = session.execute(stmt).scalar_one_or_none() original_credentials = ( json.loads(credential_record.encrypted_config) if credential_record and credential_record.encrypted_config @@ -917,31 +899,23 @@ class ProviderConfiguration(BaseModel): except JSONDecodeError: original_credentials = {} - # decrypt credentials - for key, value in credentials.items(): - if key in provider_credential_secret_variables: - # if send [__HIDDEN__] in secret input, it will be same as original value - if value == HIDDEN_VALUE and key in original_credentials: - credentials[key] = encrypter.decrypt_token( - tenant_id=self.tenant_id, token=original_credentials[key] - ) - - model_provider_factory = self.get_model_provider_factory() - validated_credentials = model_provider_factory.model_credentials_validate( - provider=self.provider.provider, model_type=model_type, model=model, credentials=credentials - ) - - for key, value in validated_credentials.items(): + for key, value in credentials.items(): if key in provider_credential_secret_variables: - validated_credentials[key] = encrypter.encrypt_token(self.tenant_id, value) + if value == HIDDEN_VALUE and key in original_credentials: + credentials[key] = encrypter.decrypt_token( + tenant_id=self.tenant_id, token=original_credentials[key] + ) - return validated_credentials + model_provider_factory = self.get_model_provider_factory() + validated_credentials = model_provider_factory.model_credentials_validate( + provider=self.provider.provider, model_type=model_type, model=model, credentials=credentials + ) - if session: - return _validate(session) - else: - with Session(db.engine) as new_session: - return _validate(new_session) + for key, value in validated_credentials.items(): + if key in provider_credential_secret_variables: + validated_credentials[key] = encrypter.encrypt_token(self.tenant_id, value) + + return validated_credentials def create_custom_model_credential( self, model_type: ModelType, model: str, credentials: dict[str, Any], credential_name: str | None @@ -954,20 +928,22 @@ class ProviderConfiguration(BaseModel): :param credentials: model credentials dict :return: """ - with Session(db.engine) as session: + with Session(db.engine) as pre_session: if credential_name: if self._check_custom_model_credential_name_exists( - model=model, model_type=model_type, credential_name=credential_name, session=session + model=model, model_type=model_type, credential_name=credential_name, session=pre_session ): raise ValueError(f"Model credential with name '{credential_name}' already exists for {model}.") else: credential_name = self._generate_custom_model_credential_name( - model=model, model_type=model_type, session=session + model=model, model_type=model_type, session=pre_session ) - # validate custom model config - credentials = self.validate_custom_model_credentials( - model_type=model_type, model=model, credentials=credentials, session=session - ) + + credentials = self.validate_custom_model_credentials( + model_type=model_type, model=model, credentials=credentials + ) + + with Session(db.engine) as session: provider_model_record = self._get_custom_model_record(model_type=model_type, model=model, session=session) try: @@ -982,7 +958,6 @@ class ProviderConfiguration(BaseModel): session.add(credential) session.flush() - # save provider model if not provider_model_record: provider_model_record = ProviderModel( tenant_id=self.tenant_id, @@ -1024,23 +999,24 @@ class ProviderConfiguration(BaseModel): :param credential_id: credential id :return: """ - with Session(db.engine) as session: + with Session(db.engine) as pre_session: if credential_name and self._check_custom_model_credential_name_exists( model=model, model_type=model_type, credential_name=credential_name, - session=session, + session=pre_session, exclude_id=credential_id, ): raise ValueError(f"Model credential with name '{credential_name}' already exists for {model}.") - # validate custom model config - credentials = self.validate_custom_model_credentials( - model_type=model_type, - model=model, - credentials=credentials, - credential_id=credential_id, - session=session, - ) + + credentials = self.validate_custom_model_credentials( + model_type=model_type, + model=model, + credentials=credentials, + credential_id=credential_id, + ) + + with Session(db.engine) as session: provider_model_record = self._get_custom_model_record(model_type=model_type, model=model, session=session) stmt = select(ProviderModelCredential).where( @@ -1055,7 +1031,6 @@ class ProviderConfiguration(BaseModel): raise ValueError("Credential record not found.") try: - # Update credential credential_record.encrypted_config = json.dumps(credentials) credential_record.updated_at = naive_utc_now() if credential_name: diff --git a/api/tests/unit_tests/core/entities/test_entities_provider_configuration.py b/api/tests/unit_tests/core/entities/test_entities_provider_configuration.py index fe2c226843..a28143026f 100644 --- a/api/tests/unit_tests/core/entities/test_entities_provider_configuration.py +++ b/api/tests/unit_tests/core/entities/test_entities_provider_configuration.py @@ -345,22 +345,26 @@ def test_validate_provider_credentials_handles_hidden_secret_value() -> None: ) ] ) - session = Mock() - session.execute.return_value.scalar_one_or_none.return_value = SimpleNamespace(encrypted_config="encrypted-old-key") + mock_session = Mock() + mock_session.execute.return_value.scalar_one_or_none.return_value = SimpleNamespace( + encrypted_config="encrypted-old-key" + ) mock_factory = Mock() mock_factory.provider_credentials_validate.return_value = {"openai_api_key": "restored-key", "region": "us"} - with patch("core.entities.provider_configuration.create_plugin_model_provider_factory", return_value=mock_factory): - with patch("core.entities.provider_configuration.encrypter.decrypt_token", return_value="restored-key"): - with patch( - "core.entities.provider_configuration.encrypter.encrypt_token", - side_effect=lambda tenant_id, value: f"enc::{value}", - ): - validated = configuration.validate_provider_credentials( - credentials={"openai_api_key": HIDDEN_VALUE, "region": "us"}, - credential_id="credential-1", - session=session, - ) + with _patched_session(mock_session): + with patch( + "core.entities.provider_configuration.create_plugin_model_provider_factory", return_value=mock_factory + ): + with patch("core.entities.provider_configuration.encrypter.decrypt_token", return_value="restored-key"): + with patch( + "core.entities.provider_configuration.encrypter.encrypt_token", + side_effect=lambda tenant_id, value: f"enc::{value}", + ): + validated = configuration.validate_provider_credentials( + credentials={"openai_api_key": HIDDEN_VALUE, "region": "us"}, + credential_id="credential-1", + ) assert validated["openai_api_key"] == "enc::restored-key" assert validated["region"] == "us" @@ -370,23 +374,15 @@ def test_validate_provider_credentials_handles_hidden_secret_value() -> None: ) -def test_validate_provider_credentials_opens_session_when_not_passed() -> None: +def test_validate_provider_credentials_without_credential_id() -> None: configuration = _build_provider_configuration() - mock_session = Mock() mock_factory = Mock() mock_factory.provider_credentials_validate.return_value = {"region": "us"} - with patch("core.entities.provider_configuration.Session") as mock_session_cls: - with patch("core.entities.provider_configuration.db") as mock_db: - mock_db.engine = Mock() - mock_session_cls.return_value.__enter__.return_value = mock_session - with patch( - "core.entities.provider_configuration.create_plugin_model_provider_factory", return_value=mock_factory - ): - validated = configuration.validate_provider_credentials(credentials={"region": "us"}) + with patch("core.entities.provider_configuration.create_plugin_model_provider_factory", return_value=mock_factory): + validated = configuration.validate_provider_credentials(credentials={"region": "us"}) assert validated == {"region": "us"} - mock_session_cls.assert_called_once() def test_switch_preferred_provider_type_returns_early_when_no_change_or_unsupported() -> None: @@ -717,18 +713,22 @@ def test_check_provider_credential_name_exists_and_model_setting_lookup() -> Non def test_validate_provider_credentials_handles_invalid_original_json() -> None: configuration = _build_provider_configuration() configuration.provider.provider_credential_schema = _build_secret_provider_schema() - session = Mock() - session.execute.return_value.scalar_one_or_none.return_value = SimpleNamespace(encrypted_config="{invalid-json") + mock_session = Mock() + mock_session.execute.return_value.scalar_one_or_none.return_value = SimpleNamespace( + encrypted_config="{invalid-json" + ) mock_factory = Mock() mock_factory.provider_credentials_validate.return_value = {"openai_api_key": "new-key"} - with patch("core.entities.provider_configuration.create_plugin_model_provider_factory", return_value=mock_factory): - with patch("core.entities.provider_configuration.encrypter.encrypt_token", return_value="enc-key"): - validated = configuration.validate_provider_credentials( - credentials={"openai_api_key": HIDDEN_VALUE}, - credential_id="cred-1", - session=session, - ) + with _patched_session(mock_session): + with patch( + "core.entities.provider_configuration.create_plugin_model_provider_factory", return_value=mock_factory + ): + with patch("core.entities.provider_configuration.encrypter.encrypt_token", return_value="enc-key"): + validated = configuration.validate_provider_credentials( + credentials={"openai_api_key": HIDDEN_VALUE}, + credential_id="cred-1", + ) assert validated == {"openai_api_key": "enc-key"} @@ -1060,37 +1060,35 @@ def test_get_custom_model_credential_uses_specific_id_or_configuration_fallback( def test_validate_custom_model_credentials_supports_hidden_reuse_and_sessionless_path() -> None: configuration = _build_provider_configuration() configuration.provider.model_credential_schema = _build_secret_model_schema() - session = Mock() - session.execute.return_value.scalar_one_or_none.return_value = SimpleNamespace( + mock_session = Mock() + mock_session.execute.return_value.scalar_one_or_none.return_value = SimpleNamespace( encrypted_config='{"openai_api_key":"enc"}' ) mock_factory = Mock() mock_factory.model_credentials_validate.return_value = {"openai_api_key": "raw"} - with patch("core.entities.provider_configuration.create_plugin_model_provider_factory", return_value=mock_factory): - with patch("core.entities.provider_configuration.encrypter.decrypt_token", return_value="raw"): - with patch("core.entities.provider_configuration.encrypter.encrypt_token", return_value="enc-new"): - validated = configuration.validate_custom_model_credentials( - model_type=ModelType.LLM, - model="gpt-4o", - credentials={"openai_api_key": HIDDEN_VALUE}, - credential_id="cred-1", - session=session, - ) - assert validated == {"openai_api_key": "enc-new"} - - session = Mock() - mock_factory = Mock() - mock_factory.model_credentials_validate.return_value = {"region": "us"} - with _patched_session(session): + with _patched_session(mock_session): with patch( "core.entities.provider_configuration.create_plugin_model_provider_factory", return_value=mock_factory ): - validated = configuration.validate_custom_model_credentials( - model_type=ModelType.LLM, - model="gpt-4o", - credentials={"region": "us"}, - ) + with patch("core.entities.provider_configuration.encrypter.decrypt_token", return_value="raw"): + with patch("core.entities.provider_configuration.encrypter.encrypt_token", return_value="enc-new"): + validated = configuration.validate_custom_model_credentials( + model_type=ModelType.LLM, + model="gpt-4o", + credentials={"openai_api_key": HIDDEN_VALUE}, + credential_id="cred-1", + ) + assert validated == {"openai_api_key": "enc-new"} + + mock_factory2 = Mock() + mock_factory2.model_credentials_validate.return_value = {"region": "us"} + with patch("core.entities.provider_configuration.create_plugin_model_provider_factory", return_value=mock_factory2): + validated = configuration.validate_custom_model_credentials( + model_type=ModelType.LLM, + model="gpt-4o", + credentials={"region": "us"}, + ) assert validated == {"region": "us"} @@ -1570,18 +1568,20 @@ def test_get_specific_provider_credential_logs_when_decrypt_fails() -> None: def test_validate_provider_credentials_uses_empty_original_when_record_missing() -> None: configuration = _build_provider_configuration() configuration.provider.provider_credential_schema = _build_secret_provider_schema() - session = Mock() - session.execute.return_value.scalar_one_or_none.return_value = None + mock_session = Mock() + mock_session.execute.return_value.scalar_one_or_none.return_value = None mock_factory = Mock() mock_factory.provider_credentials_validate.return_value = {"openai_api_key": "raw"} - with patch("core.entities.provider_configuration.create_plugin_model_provider_factory", return_value=mock_factory): - with patch("core.entities.provider_configuration.encrypter.encrypt_token", return_value="enc-new"): - validated = configuration.validate_provider_credentials( - credentials={"openai_api_key": HIDDEN_VALUE}, - credential_id="cred-1", - session=session, - ) + with _patched_session(mock_session): + with patch( + "core.entities.provider_configuration.create_plugin_model_provider_factory", return_value=mock_factory + ): + with patch("core.entities.provider_configuration.encrypter.encrypt_token", return_value="enc-new"): + validated = configuration.validate_provider_credentials( + credentials={"openai_api_key": HIDDEN_VALUE}, + credential_id="cred-1", + ) assert validated == {"openai_api_key": "enc-new"} @@ -1692,20 +1692,24 @@ def test_get_specific_custom_model_credential_logs_when_decrypt_fails() -> None: def test_validate_custom_model_credentials_handles_invalid_original_json() -> None: configuration = _build_provider_configuration() configuration.provider.model_credential_schema = _build_secret_model_schema() - session = Mock() - session.execute.return_value.scalar_one_or_none.return_value = SimpleNamespace(encrypted_config="{invalid-json") + mock_session = Mock() + mock_session.execute.return_value.scalar_one_or_none.return_value = SimpleNamespace( + encrypted_config="{invalid-json" + ) mock_factory = Mock() mock_factory.model_credentials_validate.return_value = {"openai_api_key": "raw"} - with patch("core.entities.provider_configuration.create_plugin_model_provider_factory", return_value=mock_factory): - with patch("core.entities.provider_configuration.encrypter.encrypt_token", return_value="enc-new"): - validated = configuration.validate_custom_model_credentials( - model_type=ModelType.LLM, - model="gpt-4o", - credentials={"openai_api_key": HIDDEN_VALUE}, - credential_id="cred-1", - session=session, - ) + with _patched_session(mock_session): + with patch( + "core.entities.provider_configuration.create_plugin_model_provider_factory", return_value=mock_factory + ): + with patch("core.entities.provider_configuration.encrypter.encrypt_token", return_value="enc-new"): + validated = configuration.validate_custom_model_credentials( + model_type=ModelType.LLM, + model="gpt-4o", + credentials={"openai_api_key": HIDDEN_VALUE}, + credential_id="cred-1", + ) assert validated == {"openai_api_key": "enc-new"} From 881a9a1a0840cd2af729495896bb74b159df96c6 Mon Sep 17 00:00:00 2001 From: Yunlu Wen Date: Fri, 17 Apr 2026 15:53:35 +0800 Subject: [PATCH 09/24] refactor(api): move trace providers (#35144) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- api/core/ops/entities/config_entity.py | 219 +---------- api/core/ops/ops_trace_manager.py | 189 ++++----- api/providers/README.md | 3 + api/providers/trace/README.md | 78 ++++ .../trace/trace-aliyun/pyproject.toml | 14 + .../src/dify_trace_aliyun}/__init__.py | 0 .../src/dify_trace_aliyun}/aliyun_trace.py | 34 +- .../src/dify_trace_aliyun/config.py | 32 ++ .../data_exporter/__init__.py | 0 .../data_exporter/traceclient.py | 4 +- .../dify_trace_aliyun}/entities/__init__.py | 0 .../entities/aliyun_trace_entity.py | 0 .../dify_trace_aliyun}/entities/semconv.py | 0 .../src/dify_trace_aliyun/py.typed} | 0 .../src/dify_trace_aliyun}/utils.py | 6 +- .../data_exporter/test_traceclient.py | 47 ++- .../entities/test_aliyun_trace_entity.py | 3 +- .../aliyun_trace/entities/test_semconv.py | 2 +- .../aliyun_trace/test_aliyun_trace.py | 12 +- .../aliyun_trace/test_aliyun_trace_utils.py | 16 +- .../tests/unit_tests/test_config_entity.py | 85 ++++ .../trace/trace-arize-phoenix/pyproject.toml | 10 + .../src/dify_trace_arize_phoenix}/__init__.py | 0 .../arize_phoenix_trace.py | 2 +- .../src/dify_trace_arize_phoenix/config.py | 45 +++ .../src/dify_trace_arize_phoenix/py.typed} | 0 .../test_arize_phoenix_trace.py | 38 +- .../unit_tests}/test_arize_phoenix_trace.py | 2 +- .../tests/unit_tests/test_config_entity.py | 88 +++++ .../trace/trace-langfuse/pyproject.toml | 10 + .../src/dify_trace_langfuse}/__init__.py | 0 .../src/dify_trace_langfuse/config.py | 19 + .../dify_trace_langfuse}/entities/__init__.py | 0 .../entities/langfuse_trace_entity.py | 0 .../dify_trace_langfuse}/langfuse_trace.py | 8 +- .../src/dify_trace_langfuse/py.typed} | 0 .../langfuse_trace/test_langfuse_trace.py | 46 +-- .../tests/unit_tests/test_config_entity.py | 42 ++ .../tests/unit_tests}/test_langfuse_trace.py | 11 +- .../trace/trace-langsmith/pyproject.toml | 10 + .../src/dify_trace_langsmith}/__init__.py | 0 .../src/dify_trace_langsmith/config.py | 20 + .../entities}/__init__.py | 0 .../entities/langsmith_trace_entity.py | 0 .../dify_trace_langsmith}/langsmith_trace.py | 8 +- .../src/dify_trace_langsmith/py.typed} | 0 .../langsmith_trace/test_langsmith_trace.py | 42 +- .../tests/unit_tests/test_config_entity.py | 35 ++ .../trace/trace-mlflow/pyproject.toml | 10 + .../src/dify_trace_mlflow}/__init__.py | 0 .../src/dify_trace_mlflow/config.py | 46 +++ .../src/dify_trace_mlflow}/mlflow_trace.py | 2 +- .../src/dify_trace_mlflow/py.typed | 0 .../mlflow_trace/test_mlflow_trace.py | 18 +- api/providers/trace/trace-opik/pyproject.toml | 10 + .../src/dify_trace_opik/__init__.py | 0 .../trace-opik/src/dify_trace_opik/config.py | 25 ++ .../src/dify_trace_opik}/opik_trace.py | 2 +- .../trace-opik/src/dify_trace_opik/py.typed | 0 .../unit_tests}/opik_trace/test_opik_trace.py | 32 +- .../tests/unit_tests/test_config_entity.py | 48 +++ .../tests/unit_tests}/test_opik_trace.py | 19 +- .../trace/trace-tencent/pyproject.toml | 14 + .../src/dify_trace_tencent/__init__.py | 0 .../src/dify_trace_tencent}/client.py | 0 .../src/dify_trace_tencent/config.py | 30 ++ .../dify_trace_tencent}/entities/__init__.py | 0 .../dify_trace_tencent}/entities/semconv.py | 0 .../entities/tencent_trace_entity.py | 0 .../src/dify_trace_tencent/py.typed | 0 .../src/dify_trace_tencent}/span_builder.py | 8 +- .../src/dify_trace_tencent}/tencent_trace.py | 10 +- .../src/dify_trace_tencent}/utils.py | 0 .../unit_tests}/tencent_trace/test_client.py | 7 +- .../tencent_trace/test_span_builder.py | 50 +-- .../tencent_trace/test_tencent_trace.py | 72 ++-- .../tencent_trace/test_tencent_trace_utils.py | 13 +- .../trace/trace-weave/pyproject.toml | 10 + .../src/dify_trace_weave/__init__.py | 0 .../src/dify_trace_weave/config.py | 29 ++ .../src/dify_trace_weave/entities/__init__.py | 0 .../entities/weave_trace_entity.py | 0 .../trace-weave/src/dify_trace_weave/py.typed | 0 .../src/dify_trace_weave}/weave_trace.py | 4 +- .../tests/unit_tests/test_config_entity.py | 61 +++ .../weave_trace/test_weave_trace.py | 36 +- api/pyproject.toml | 39 +- api/pyrefly-local-excludes.txt | 10 +- api/pyrightconfig.json | 3 +- .../unit_tests/core/ops/test_config_entity.py | 363 +----------------- api/uv.lock | 176 ++++++++- dev/pytest/pytest_unit_tests.sh | 1 + 92 files changed, 1357 insertions(+), 971 deletions(-) create mode 100644 api/providers/trace/README.md create mode 100644 api/providers/trace/trace-aliyun/pyproject.toml rename api/{core/ops/aliyun_trace => providers/trace/trace-aliyun/src/dify_trace_aliyun}/__init__.py (100%) rename api/{core/ops/aliyun_trace => providers/trace/trace-aliyun/src/dify_trace_aliyun}/aliyun_trace.py (98%) create mode 100644 api/providers/trace/trace-aliyun/src/dify_trace_aliyun/config.py rename api/{core/ops/aliyun_trace => providers/trace/trace-aliyun/src/dify_trace_aliyun}/data_exporter/__init__.py (100%) rename api/{core/ops/aliyun_trace => providers/trace/trace-aliyun/src/dify_trace_aliyun}/data_exporter/traceclient.py (98%) rename api/{core/ops/aliyun_trace => providers/trace/trace-aliyun/src/dify_trace_aliyun}/entities/__init__.py (100%) rename api/{core/ops/aliyun_trace => providers/trace/trace-aliyun/src/dify_trace_aliyun}/entities/aliyun_trace_entity.py (100%) rename api/{core/ops/aliyun_trace => providers/trace/trace-aliyun/src/dify_trace_aliyun}/entities/semconv.py (100%) rename api/{core/ops/arize_phoenix_trace/__init__.py => providers/trace/trace-aliyun/src/dify_trace_aliyun/py.typed} (100%) rename api/{core/ops/aliyun_trace => providers/trace/trace-aliyun/src/dify_trace_aliyun}/utils.py (97%) rename api/{tests/unit_tests/core/ops => providers/trace/trace-aliyun/tests/unit_tests}/aliyun_trace/data_exporter/test_traceclient.py (86%) rename api/{tests/unit_tests/core/ops => providers/trace/trace-aliyun/tests/unit_tests}/aliyun_trace/entities/test_aliyun_trace_entity.py (97%) rename api/{tests/unit_tests/core/ops => providers/trace/trace-aliyun/tests/unit_tests}/aliyun_trace/entities/test_semconv.py (97%) rename api/{tests/unit_tests/core/ops => providers/trace/trace-aliyun/tests/unit_tests}/aliyun_trace/test_aliyun_trace.py (99%) rename api/{tests/unit_tests/core/ops => providers/trace/trace-aliyun/tests/unit_tests}/aliyun_trace/test_aliyun_trace_utils.py (95%) create mode 100644 api/providers/trace/trace-aliyun/tests/unit_tests/test_config_entity.py create mode 100644 api/providers/trace/trace-arize-phoenix/pyproject.toml rename api/{core/ops/langfuse_trace => providers/trace/trace-arize-phoenix/src/dify_trace_arize_phoenix}/__init__.py (100%) rename api/{core/ops/arize_phoenix_trace => providers/trace/trace-arize-phoenix/src/dify_trace_arize_phoenix}/arize_phoenix_trace.py (99%) create mode 100644 api/providers/trace/trace-arize-phoenix/src/dify_trace_arize_phoenix/config.py rename api/{core/ops/langfuse_trace/entities/__init__.py => providers/trace/trace-arize-phoenix/src/dify_trace_arize_phoenix/py.typed} (100%) rename api/{tests/unit_tests/core/ops => providers/trace/trace-arize-phoenix/tests/unit_tests}/arize_phoenix_trace/test_arize_phoenix_trace.py (91%) rename api/{tests/unit_tests/core/ops => providers/trace/trace-arize-phoenix/tests/unit_tests}/test_arize_phoenix_trace.py (94%) create mode 100644 api/providers/trace/trace-arize-phoenix/tests/unit_tests/test_config_entity.py create mode 100644 api/providers/trace/trace-langfuse/pyproject.toml rename api/{core/ops/langsmith_trace => providers/trace/trace-langfuse/src/dify_trace_langfuse}/__init__.py (100%) create mode 100644 api/providers/trace/trace-langfuse/src/dify_trace_langfuse/config.py rename api/{core/ops/langsmith_trace => providers/trace/trace-langfuse/src/dify_trace_langfuse}/entities/__init__.py (100%) rename api/{core/ops/langfuse_trace => providers/trace/trace-langfuse/src/dify_trace_langfuse}/entities/langfuse_trace_entity.py (100%) rename api/{core/ops/langfuse_trace => providers/trace/trace-langfuse/src/dify_trace_langfuse}/langfuse_trace.py (99%) rename api/{core/ops/mlflow_trace/__init__.py => providers/trace/trace-langfuse/src/dify_trace_langfuse/py.typed} (100%) rename api/{tests/unit_tests/core/ops => providers/trace/trace-langfuse/tests/unit_tests}/langfuse_trace/test_langfuse_trace.py (93%) create mode 100644 api/providers/trace/trace-langfuse/tests/unit_tests/test_config_entity.py rename api/{tests/unit_tests/core/ops => providers/trace/trace-langfuse/tests/unit_tests}/test_langfuse_trace.py (92%) create mode 100644 api/providers/trace/trace-langsmith/pyproject.toml rename api/{core/ops/opik_trace => providers/trace/trace-langsmith/src/dify_trace_langsmith}/__init__.py (100%) create mode 100644 api/providers/trace/trace-langsmith/src/dify_trace_langsmith/config.py rename api/{core/ops/tencent_trace => providers/trace/trace-langsmith/src/dify_trace_langsmith/entities}/__init__.py (100%) rename api/{core/ops/langsmith_trace => providers/trace/trace-langsmith/src/dify_trace_langsmith}/entities/langsmith_trace_entity.py (100%) rename api/{core/ops/langsmith_trace => providers/trace/trace-langsmith/src/dify_trace_langsmith}/langsmith_trace.py (99%) rename api/{core/ops/weave_trace/__init__.py => providers/trace/trace-langsmith/src/dify_trace_langsmith/py.typed} (100%) rename api/{tests/unit_tests/core/ops => providers/trace/trace-langsmith/tests/unit_tests}/langsmith_trace/test_langsmith_trace.py (91%) create mode 100644 api/providers/trace/trace-langsmith/tests/unit_tests/test_config_entity.py create mode 100644 api/providers/trace/trace-mlflow/pyproject.toml rename api/{core/ops/weave_trace/entities => providers/trace/trace-mlflow/src/dify_trace_mlflow}/__init__.py (100%) create mode 100644 api/providers/trace/trace-mlflow/src/dify_trace_mlflow/config.py rename api/{core/ops/mlflow_trace => providers/trace/trace-mlflow/src/dify_trace_mlflow}/mlflow_trace.py (99%) create mode 100644 api/providers/trace/trace-mlflow/src/dify_trace_mlflow/py.typed rename api/{tests/unit_tests/core/ops => providers/trace/trace-mlflow/tests/unit_tests}/mlflow_trace/test_mlflow_trace.py (98%) create mode 100644 api/providers/trace/trace-opik/pyproject.toml create mode 100644 api/providers/trace/trace-opik/src/dify_trace_opik/__init__.py create mode 100644 api/providers/trace/trace-opik/src/dify_trace_opik/config.py rename api/{core/ops/opik_trace => providers/trace/trace-opik/src/dify_trace_opik}/opik_trace.py (99%) create mode 100644 api/providers/trace/trace-opik/src/dify_trace_opik/py.typed rename api/{tests/unit_tests/core/ops => providers/trace/trace-opik/tests/unit_tests}/opik_trace/test_opik_trace.py (93%) create mode 100644 api/providers/trace/trace-opik/tests/unit_tests/test_config_entity.py rename api/{tests/unit_tests/core/ops => providers/trace/trace-opik/tests/unit_tests}/test_opik_trace.py (94%) create mode 100644 api/providers/trace/trace-tencent/pyproject.toml create mode 100644 api/providers/trace/trace-tencent/src/dify_trace_tencent/__init__.py rename api/{core/ops/tencent_trace => providers/trace/trace-tencent/src/dify_trace_tencent}/client.py (100%) create mode 100644 api/providers/trace/trace-tencent/src/dify_trace_tencent/config.py rename api/{core/ops/tencent_trace => providers/trace/trace-tencent/src/dify_trace_tencent}/entities/__init__.py (100%) rename api/{core/ops/tencent_trace => providers/trace/trace-tencent/src/dify_trace_tencent}/entities/semconv.py (100%) rename api/{core/ops/tencent_trace => providers/trace/trace-tencent/src/dify_trace_tencent}/entities/tencent_trace_entity.py (100%) create mode 100644 api/providers/trace/trace-tencent/src/dify_trace_tencent/py.typed rename api/{core/ops/tencent_trace => providers/trace/trace-tencent/src/dify_trace_tencent}/span_builder.py (98%) rename api/{core/ops/tencent_trace => providers/trace/trace-tencent/src/dify_trace_tencent}/tencent_trace.py (98%) rename api/{core/ops/tencent_trace => providers/trace/trace-tencent/src/dify_trace_tencent}/utils.py (100%) rename api/{tests/unit_tests/core/ops => providers/trace/trace-tencent/tests/unit_tests}/tencent_trace/test_client.py (98%) rename api/{tests/unit_tests/core/ops => providers/trace/trace-tencent/tests/unit_tests}/tencent_trace/test_span_builder.py (89%) rename api/{tests/unit_tests/core/ops => providers/trace/trace-tencent/tests/unit_tests}/tencent_trace/test_tencent_trace.py (89%) rename api/{tests/unit_tests/core/ops => providers/trace/trace-tencent/tests/unit_tests}/tencent_trace/test_tencent_trace_utils.py (88%) create mode 100644 api/providers/trace/trace-weave/pyproject.toml create mode 100644 api/providers/trace/trace-weave/src/dify_trace_weave/__init__.py create mode 100644 api/providers/trace/trace-weave/src/dify_trace_weave/config.py create mode 100644 api/providers/trace/trace-weave/src/dify_trace_weave/entities/__init__.py rename api/{core/ops/weave_trace => providers/trace/trace-weave/src/dify_trace_weave}/entities/weave_trace_entity.py (100%) create mode 100644 api/providers/trace/trace-weave/src/dify_trace_weave/py.typed rename api/{core/ops/weave_trace => providers/trace/trace-weave/src/dify_trace_weave}/weave_trace.py (99%) create mode 100644 api/providers/trace/trace-weave/tests/unit_tests/test_config_entity.py rename api/{tests/unit_tests/core/ops => providers/trace/trace-weave/tests/unit_tests}/weave_trace/test_weave_trace.py (97%) diff --git a/api/core/ops/entities/config_entity.py b/api/core/ops/entities/config_entity.py index fda00ac3b9..d78ce90aa1 100644 --- a/api/core/ops/entities/config_entity.py +++ b/api/core/ops/entities/config_entity.py @@ -1,8 +1,8 @@ from enum import StrEnum -from pydantic import BaseModel, ValidationInfo, field_validator +from pydantic import BaseModel -from core.ops.utils import validate_integer_id, validate_project_name, validate_url, validate_url_with_path +from core.ops.utils import validate_project_name, validate_url class TracingProviderEnum(StrEnum): @@ -52,220 +52,5 @@ class BaseTracingConfig(BaseModel): return validate_project_name(v, default_name) -class ArizeConfig(BaseTracingConfig): - """ - Model class for Arize tracing config. - """ - - api_key: str | None = None - space_id: str | None = None - project: str | None = None - endpoint: str = "https://otlp.arize.com" - - @field_validator("project") - @classmethod - def project_validator(cls, v, info: ValidationInfo): - return cls.validate_project_field(v, "default") - - @field_validator("endpoint") - @classmethod - def endpoint_validator(cls, v, info: ValidationInfo): - return cls.validate_endpoint_url(v, "https://otlp.arize.com") - - -class PhoenixConfig(BaseTracingConfig): - """ - Model class for Phoenix tracing config. - """ - - api_key: str | None = None - project: str | None = None - endpoint: str = "https://app.phoenix.arize.com" - - @field_validator("project") - @classmethod - def project_validator(cls, v, info: ValidationInfo): - return cls.validate_project_field(v, "default") - - @field_validator("endpoint") - @classmethod - def endpoint_validator(cls, v, info: ValidationInfo): - return validate_url_with_path(v, "https://app.phoenix.arize.com") - - -class LangfuseConfig(BaseTracingConfig): - """ - Model class for Langfuse tracing config. - """ - - public_key: str - secret_key: str - host: str = "https://api.langfuse.com" - - @field_validator("host") - @classmethod - def host_validator(cls, v, info: ValidationInfo): - return validate_url_with_path(v, "https://api.langfuse.com") - - -class LangSmithConfig(BaseTracingConfig): - """ - Model class for Langsmith tracing config. - """ - - api_key: str - project: str - endpoint: str = "https://api.smith.langchain.com" - - @field_validator("endpoint") - @classmethod - def endpoint_validator(cls, v, info: ValidationInfo): - # LangSmith only allows HTTPS - return validate_url(v, "https://api.smith.langchain.com", allowed_schemes=("https",)) - - -class OpikConfig(BaseTracingConfig): - """ - Model class for Opik tracing config. - """ - - api_key: str | None = None - project: str | None = None - workspace: str | None = None - url: str = "https://www.comet.com/opik/api/" - - @field_validator("project") - @classmethod - def project_validator(cls, v, info: ValidationInfo): - return cls.validate_project_field(v, "Default Project") - - @field_validator("url") - @classmethod - def url_validator(cls, v, info: ValidationInfo): - return validate_url_with_path(v, "https://www.comet.com/opik/api/", required_suffix="/api/") - - -class WeaveConfig(BaseTracingConfig): - """ - Model class for Weave tracing config. - """ - - api_key: str - entity: str | None = None - project: str - endpoint: str = "https://trace.wandb.ai" - host: str | None = None - - @field_validator("endpoint") - @classmethod - def endpoint_validator(cls, v, info: ValidationInfo): - # Weave only allows HTTPS for endpoint - return validate_url(v, "https://trace.wandb.ai", allowed_schemes=("https",)) - - @field_validator("host") - @classmethod - def host_validator(cls, v, info: ValidationInfo): - if v is not None and v.strip() != "": - return validate_url(v, v, allowed_schemes=("https", "http")) - return v - - -class AliyunConfig(BaseTracingConfig): - """ - Model class for Aliyun tracing config. - """ - - app_name: str = "dify_app" - license_key: str - endpoint: str - - @field_validator("app_name") - @classmethod - def app_name_validator(cls, v, info: ValidationInfo): - return cls.validate_project_field(v, "dify_app") - - @field_validator("license_key") - @classmethod - def license_key_validator(cls, v, info: ValidationInfo): - if not v or v.strip() == "": - raise ValueError("License key cannot be empty") - return v - - @field_validator("endpoint") - @classmethod - def endpoint_validator(cls, v, info: ValidationInfo): - # aliyun uses two URL formats, which may include a URL path - return validate_url_with_path(v, "https://tracing-analysis-dc-hz.aliyuncs.com") - - -class TencentConfig(BaseTracingConfig): - """ - Tencent APM tracing config - """ - - token: str - endpoint: str - service_name: str - - @field_validator("token") - @classmethod - def token_validator(cls, v, info: ValidationInfo): - if not v or v.strip() == "": - raise ValueError("Token cannot be empty") - return v - - @field_validator("endpoint") - @classmethod - def endpoint_validator(cls, v, info: ValidationInfo): - return cls.validate_endpoint_url(v, "https://apm.tencentcloudapi.com") - - @field_validator("service_name") - @classmethod - def service_name_validator(cls, v, info: ValidationInfo): - return cls.validate_project_field(v, "dify_app") - - -class MLflowConfig(BaseTracingConfig): - """ - Model class for MLflow tracing config. - """ - - tracking_uri: str = "http://localhost:5000" - experiment_id: str = "0" # Default experiment id in MLflow is 0 - username: str | None = None - password: str | None = None - - @field_validator("tracking_uri") - @classmethod - def tracking_uri_validator(cls, v, info: ValidationInfo): - if isinstance(v, str) and v.startswith("databricks"): - raise ValueError( - "Please use Databricks tracing config below to record traces to Databricks-managed MLflow instances." - ) - return validate_url_with_path(v, "http://localhost:5000") - - @field_validator("experiment_id") - @classmethod - def experiment_id_validator(cls, v, info: ValidationInfo): - return validate_integer_id(v) - - -class DatabricksConfig(BaseTracingConfig): - """ - Model class for Databricks (Databricks-managed MLflow) tracing config. - """ - - experiment_id: str - host: str - client_id: str | None = None - client_secret: str | None = None - personal_access_token: str | None = None - - @field_validator("experiment_id") - @classmethod - def experiment_id_validator(cls, v, info: ValidationInfo): - return validate_integer_id(v) - - OPS_FILE_PATH = "ops_trace/" OPS_TRACE_FAILED_KEY = "FAILED_OPS_TRACE" diff --git a/api/core/ops/ops_trace_manager.py b/api/core/ops/ops_trace_manager.py index cd63951537..e7ba6e502b 100644 --- a/api/core/ops/ops_trace_manager.py +++ b/api/core/ops/ops_trace_manager.py @@ -204,114 +204,117 @@ class TracingProviderConfigEntry(TypedDict): class OpsTraceProviderConfigMap(collections.UserDict[str, TracingProviderConfigEntry]): def __getitem__(self, provider: str) -> TracingProviderConfigEntry: - match provider: - case TracingProviderEnum.LANGFUSE: - from core.ops.entities.config_entity import LangfuseConfig - from core.ops.langfuse_trace.langfuse_trace import LangFuseDataTrace + try: + match provider: + case TracingProviderEnum.LANGFUSE: + from dify_trace_langfuse.config import LangfuseConfig + from dify_trace_langfuse.langfuse_trace import LangFuseDataTrace - return { - "config_class": LangfuseConfig, - "secret_keys": ["public_key", "secret_key"], - "other_keys": ["host", "project_key"], - "trace_instance": LangFuseDataTrace, - } + return { + "config_class": LangfuseConfig, + "secret_keys": ["public_key", "secret_key"], + "other_keys": ["host", "project_key"], + "trace_instance": LangFuseDataTrace, + } - case TracingProviderEnum.LANGSMITH: - from core.ops.entities.config_entity import LangSmithConfig - from core.ops.langsmith_trace.langsmith_trace import LangSmithDataTrace + case TracingProviderEnum.LANGSMITH: + from dify_trace_langsmith.config import LangSmithConfig + from dify_trace_langsmith.langsmith_trace import LangSmithDataTrace - return { - "config_class": LangSmithConfig, - "secret_keys": ["api_key"], - "other_keys": ["project", "endpoint"], - "trace_instance": LangSmithDataTrace, - } + return { + "config_class": LangSmithConfig, + "secret_keys": ["api_key"], + "other_keys": ["project", "endpoint"], + "trace_instance": LangSmithDataTrace, + } - case TracingProviderEnum.OPIK: - from core.ops.entities.config_entity import OpikConfig - from core.ops.opik_trace.opik_trace import OpikDataTrace + case TracingProviderEnum.OPIK: + from dify_trace_opik.config import OpikConfig + from dify_trace_opik.opik_trace import OpikDataTrace - return { - "config_class": OpikConfig, - "secret_keys": ["api_key"], - "other_keys": ["project", "url", "workspace"], - "trace_instance": OpikDataTrace, - } + return { + "config_class": OpikConfig, + "secret_keys": ["api_key"], + "other_keys": ["project", "url", "workspace"], + "trace_instance": OpikDataTrace, + } - case TracingProviderEnum.WEAVE: - from core.ops.entities.config_entity import WeaveConfig - from core.ops.weave_trace.weave_trace import WeaveDataTrace + case TracingProviderEnum.WEAVE: + from dify_trace_weave.config import WeaveConfig + from dify_trace_weave.weave_trace import WeaveDataTrace - return { - "config_class": WeaveConfig, - "secret_keys": ["api_key"], - "other_keys": ["project", "entity", "endpoint", "host"], - "trace_instance": WeaveDataTrace, - } - case TracingProviderEnum.ARIZE: - from core.ops.arize_phoenix_trace.arize_phoenix_trace import ArizePhoenixDataTrace - from core.ops.entities.config_entity import ArizeConfig + return { + "config_class": WeaveConfig, + "secret_keys": ["api_key"], + "other_keys": ["project", "entity", "endpoint", "host"], + "trace_instance": WeaveDataTrace, + } + case TracingProviderEnum.ARIZE: + from dify_trace_arize_phoenix.arize_phoenix_trace import ArizePhoenixDataTrace + from dify_trace_arize_phoenix.config import ArizeConfig - return { - "config_class": ArizeConfig, - "secret_keys": ["api_key", "space_id"], - "other_keys": ["project", "endpoint"], - "trace_instance": ArizePhoenixDataTrace, - } - case TracingProviderEnum.PHOENIX: - from core.ops.arize_phoenix_trace.arize_phoenix_trace import ArizePhoenixDataTrace - from core.ops.entities.config_entity import PhoenixConfig + return { + "config_class": ArizeConfig, + "secret_keys": ["api_key", "space_id"], + "other_keys": ["project", "endpoint"], + "trace_instance": ArizePhoenixDataTrace, + } + case TracingProviderEnum.PHOENIX: + from dify_trace_arize_phoenix.arize_phoenix_trace import ArizePhoenixDataTrace + from dify_trace_arize_phoenix.config import PhoenixConfig - return { - "config_class": PhoenixConfig, - "secret_keys": ["api_key"], - "other_keys": ["project", "endpoint"], - "trace_instance": ArizePhoenixDataTrace, - } - case TracingProviderEnum.ALIYUN: - from core.ops.aliyun_trace.aliyun_trace import AliyunDataTrace - from core.ops.entities.config_entity import AliyunConfig + return { + "config_class": PhoenixConfig, + "secret_keys": ["api_key"], + "other_keys": ["project", "endpoint"], + "trace_instance": ArizePhoenixDataTrace, + } + case TracingProviderEnum.ALIYUN: + from dify_trace_aliyun.aliyun_trace import AliyunDataTrace + from dify_trace_aliyun.config import AliyunConfig - return { - "config_class": AliyunConfig, - "secret_keys": ["license_key"], - "other_keys": ["endpoint", "app_name"], - "trace_instance": AliyunDataTrace, - } - case TracingProviderEnum.MLFLOW: - from core.ops.entities.config_entity import MLflowConfig - from core.ops.mlflow_trace.mlflow_trace import MLflowDataTrace + return { + "config_class": AliyunConfig, + "secret_keys": ["license_key"], + "other_keys": ["endpoint", "app_name"], + "trace_instance": AliyunDataTrace, + } + case TracingProviderEnum.MLFLOW: + from dify_trace_mlflow.config import MLflowConfig + from dify_trace_mlflow.mlflow_trace import MLflowDataTrace - return { - "config_class": MLflowConfig, - "secret_keys": ["password"], - "other_keys": ["tracking_uri", "experiment_id", "username"], - "trace_instance": MLflowDataTrace, - } - case TracingProviderEnum.DATABRICKS: - from core.ops.entities.config_entity import DatabricksConfig - from core.ops.mlflow_trace.mlflow_trace import MLflowDataTrace + return { + "config_class": MLflowConfig, + "secret_keys": ["password"], + "other_keys": ["tracking_uri", "experiment_id", "username"], + "trace_instance": MLflowDataTrace, + } + case TracingProviderEnum.DATABRICKS: + from dify_trace_mlflow.config import DatabricksConfig + from dify_trace_mlflow.mlflow_trace import MLflowDataTrace - return { - "config_class": DatabricksConfig, - "secret_keys": ["personal_access_token", "client_secret"], - "other_keys": ["host", "client_id", "experiment_id"], - "trace_instance": MLflowDataTrace, - } + return { + "config_class": DatabricksConfig, + "secret_keys": ["personal_access_token", "client_secret"], + "other_keys": ["host", "client_id", "experiment_id"], + "trace_instance": MLflowDataTrace, + } - case TracingProviderEnum.TENCENT: - from core.ops.entities.config_entity import TencentConfig - from core.ops.tencent_trace.tencent_trace import TencentDataTrace + case TracingProviderEnum.TENCENT: + from dify_trace_tencent.config import TencentConfig + from dify_trace_tencent.tencent_trace import TencentDataTrace - return { - "config_class": TencentConfig, - "secret_keys": ["token"], - "other_keys": ["endpoint", "service_name"], - "trace_instance": TencentDataTrace, - } + return { + "config_class": TencentConfig, + "secret_keys": ["token"], + "other_keys": ["endpoint", "service_name"], + "trace_instance": TencentDataTrace, + } - case _: - raise KeyError(f"Unsupported tracing provider: {provider}") + case _: + raise KeyError(f"Unsupported tracing provider: {provider}") + except ImportError: + raise ImportError(f"Provider {provider} is not installed.") provider_config_map = OpsTraceProviderConfigMap() diff --git a/api/providers/README.md b/api/providers/README.md index a00ec8bc52..5d5e6db9af 100644 --- a/api/providers/README.md +++ b/api/providers/README.md @@ -10,3 +10,6 @@ This directory holds **optional workspace packages** that plug into Dify’s API Provider tests often live next to the package, e.g. `providers///tests/unit_tests/`. Shared fixtures may live under `providers/` (e.g. `conftest.py`). +## Excluding Providers + +In order to build with selected providers, use `--no-group vdb-all` and `--no-group trace-all` to disable default ones, then use `--group vdb-` and `--group trace-` to enable specific providers. diff --git a/api/providers/trace/README.md b/api/providers/trace/README.md new file mode 100644 index 0000000000..a7ffa5ed26 --- /dev/null +++ b/api/providers/trace/README.md @@ -0,0 +1,78 @@ +# Trace providers + +This directory holds **optional workspace packages** that send Dify **ops tracing** data (workflows, messages, tools, moderation, etc.) to an external observability backend (Langfuse, LangSmith, OpenTelemetry-style exporters, and others). + +Unlike VDB providers, trace plugins are **not** discovered via entry points. The API core imports your package **explicitly** from `core/ops/ops_trace_manager.py` after you register the provider id and mapping. + +## Architecture + +| Layer | Location | Role | +|--------|----------|------| +| Contracts | `api/core/ops/base_trace_instance.py`, `api/core/ops/entities/trace_entity.py`, `api/core/ops/entities/config_entity.py` | `BaseTraceInstance`, `BaseTracingConfig`, and typed `*TraceInfo` payloads | +| Registry | `api/core/ops/ops_trace_manager.py` | `TracingProviderEnum`, `OpsTraceProviderConfigMap` — maps provider **string** → config class, encrypted keys, and trace class | +| Your package | `api/providers/trace/trace-/` | Pydantic config + subclass of `BaseTraceInstance` | + +At runtime, `OpsTraceManager` decrypts stored credentials, builds your config model, caches a trace instance, and calls `trace(trace_info)` with a concrete `BaseTraceInfo` subtype. + +## What you implement + +### 1. Config model (`BaseTracingConfig`) + +Subclass `BaseTracingConfig` from `core.ops.entities.config_entity`. Use Pydantic validators; reuse helpers from `core.ops.utils` (for example `validate_url`, `validate_url_with_path`, `validate_project_name`) where appropriate. + +Fields fall into two groups used by the manager: + +- **`secret_keys`** — names of fields that are **encrypted at rest** (API keys, tokens, passwords). +- **`other_keys`** — non-secret connection settings (hosts, project names, endpoints). + +List these key names in your `OpsTraceProviderConfigMap` entry so encrypt/decrypt and merge logic stay correct. + +### 2. Trace instance (`BaseTraceInstance`) + +Subclass `BaseTraceInstance` and implement: + +```python +def trace(self, trace_info: BaseTraceInfo) -> None: + ... +``` + +Dispatch on the concrete type with `isinstance` (see `trace_langfuse` or `trace_langsmith` for full patterns). Payload types are defined in `core/ops/entities/trace_entity.py`, including: + +- `WorkflowTraceInfo`, `WorkflowNodeTraceInfo`, `DraftNodeExecutionTrace` +- `MessageTraceInfo`, `ToolTraceInfo`, `ModerationTraceInfo`, `SuggestedQuestionTraceInfo` +- `DatasetRetrievalTraceInfo`, `GenerateNameTraceInfo`, `PromptGenerationTraceInfo` + +You may ignore categories your backend does not support; existing providers often no-op unhandled types. + +Optional: use `get_service_account_with_tenant(app_id)` from the base class when you need tenant-scoped account context. + +### 3. Register in the API core + +Upstream changes are required so Dify knows your provider exists: + +1. **`TracingProviderEnum`** (`api/core/ops/entities/config_entity.py`) — add a new member whose **value** is the stable string stored in app tracing config (e.g. `"mybackend"`). +2. **`OpsTraceProviderConfigMap.__getitem__`** (`api/core/ops/ops_trace_manager.py`) — add a `match` case for that enum member returning: + - `config_class`: your Pydantic config type + - `secret_keys` / `other_keys`: lists of field names as above + - `trace_instance`: your `BaseTraceInstance` subclass + Lazy-import your package inside the case so missing optional installs raise a clear `ImportError`. + +If the `match` case is missing, the provider string will not resolve and tracing will be disabled for that app. + +## Package layout + +Each provider is a normal uv workspace member, for example: + +- `api/providers/trace/trace-/pyproject.toml` — project name `dify-trace-`, dependencies on vendor SDKs +- `api/providers/trace/trace-/src/dify_trace_/` — `config.py`, `_trace.py`, optional `entities/`, and an empty **`py.typed`** file (PEP 561) so the API type checker treats the package as typed; list `py.typed` under `[tool.setuptools.package-data]` for that import name in `pyproject.toml`. + +Reference implementations: `trace-langfuse/`, `trace-langsmith/`, `trace-opik/`. + +## Wiring into the `api` workspace + +In `api/pyproject.toml`: + +1. **`[tool.uv.sources]`** — `dify-trace- = { workspace = true }` +2. **`[dependency-groups]`** — add `trace- = ["dify-trace-"]` and include `dify-trace-` in `trace-all` if it should ship with the default bundle + +After changing metadata, run **`uv sync`** from `api/`. diff --git a/api/providers/trace/trace-aliyun/pyproject.toml b/api/providers/trace/trace-aliyun/pyproject.toml new file mode 100644 index 0000000000..bcef7e9fb1 --- /dev/null +++ b/api/providers/trace/trace-aliyun/pyproject.toml @@ -0,0 +1,14 @@ +[project] +name = "dify-trace-aliyun" +version = "0.0.1" +dependencies = [ + # versions inherited from parent + "opentelemetry-api", + "opentelemetry-exporter-otlp-proto-grpc", + "opentelemetry-sdk", + "opentelemetry-semantic-conventions", +] +description = "Dify ops tracing provider (Aliyun)." + +[tool.setuptools.packages.find] +where = ["src"] diff --git a/api/core/ops/aliyun_trace/__init__.py b/api/providers/trace/trace-aliyun/src/dify_trace_aliyun/__init__.py similarity index 100% rename from api/core/ops/aliyun_trace/__init__.py rename to api/providers/trace/trace-aliyun/src/dify_trace_aliyun/__init__.py diff --git a/api/core/ops/aliyun_trace/aliyun_trace.py b/api/providers/trace/trace-aliyun/src/dify_trace_aliyun/aliyun_trace.py similarity index 98% rename from api/core/ops/aliyun_trace/aliyun_trace.py rename to api/providers/trace/trace-aliyun/src/dify_trace_aliyun/aliyun_trace.py index 76e81242f4..54d2f8167f 100644 --- a/api/core/ops/aliyun_trace/aliyun_trace.py +++ b/api/providers/trace/trace-aliyun/src/dify_trace_aliyun/aliyun_trace.py @@ -4,7 +4,20 @@ from collections.abc import Sequence from opentelemetry.trace import SpanKind from sqlalchemy.orm import sessionmaker -from core.ops.aliyun_trace.data_exporter.traceclient import ( +from core.ops.base_trace_instance import BaseTraceInstance +from core.ops.entities.trace_entity import ( + BaseTraceInfo, + DatasetRetrievalTraceInfo, + GenerateNameTraceInfo, + MessageTraceInfo, + ModerationTraceInfo, + SuggestedQuestionTraceInfo, + ToolTraceInfo, + WorkflowTraceInfo, +) +from core.repositories import DifyCoreRepositoryFactory +from dify_trace_aliyun.config import AliyunConfig +from dify_trace_aliyun.data_exporter.traceclient import ( TraceClient, build_endpoint, convert_datetime_to_nanoseconds, @@ -12,8 +25,8 @@ from core.ops.aliyun_trace.data_exporter.traceclient import ( convert_to_trace_id, generate_span_id, ) -from core.ops.aliyun_trace.entities.aliyun_trace_entity import SpanData, TraceMetadata -from core.ops.aliyun_trace.entities.semconv import ( +from dify_trace_aliyun.entities.aliyun_trace_entity import SpanData, TraceMetadata +from dify_trace_aliyun.entities.semconv import ( DIFY_APP_ID, GEN_AI_COMPLETION, GEN_AI_INPUT_MESSAGE, @@ -32,7 +45,7 @@ from core.ops.aliyun_trace.entities.semconv import ( TOOL_PARAMETERS, GenAISpanKind, ) -from core.ops.aliyun_trace.utils import ( +from dify_trace_aliyun.utils import ( create_common_span_attributes, create_links_from_trace_id, create_status_from_error, @@ -44,19 +57,6 @@ from core.ops.aliyun_trace.utils import ( get_workflow_node_status, serialize_json_data, ) -from core.ops.base_trace_instance import BaseTraceInstance -from core.ops.entities.config_entity import AliyunConfig -from core.ops.entities.trace_entity import ( - BaseTraceInfo, - DatasetRetrievalTraceInfo, - GenerateNameTraceInfo, - MessageTraceInfo, - ModerationTraceInfo, - SuggestedQuestionTraceInfo, - ToolTraceInfo, - WorkflowTraceInfo, -) -from core.repositories import DifyCoreRepositoryFactory from extensions.ext_database import db from graphon.entities import WorkflowNodeExecution from graphon.enums import BuiltinNodeTypes, WorkflowNodeExecutionMetadataKey diff --git a/api/providers/trace/trace-aliyun/src/dify_trace_aliyun/config.py b/api/providers/trace/trace-aliyun/src/dify_trace_aliyun/config.py new file mode 100644 index 0000000000..e0133e6cc9 --- /dev/null +++ b/api/providers/trace/trace-aliyun/src/dify_trace_aliyun/config.py @@ -0,0 +1,32 @@ +from pydantic import ValidationInfo, field_validator + +from core.ops.entities.config_entity import BaseTracingConfig +from core.ops.utils import validate_url_with_path + + +class AliyunConfig(BaseTracingConfig): + """ + Model class for Aliyun tracing config. + """ + + app_name: str = "dify_app" + license_key: str + endpoint: str + + @field_validator("app_name") + @classmethod + def app_name_validator(cls, v, info: ValidationInfo): + return cls.validate_project_field(v, "dify_app") + + @field_validator("license_key") + @classmethod + def license_key_validator(cls, v, info: ValidationInfo): + if not v or v.strip() == "": + raise ValueError("License key cannot be empty") + return v + + @field_validator("endpoint") + @classmethod + def endpoint_validator(cls, v, info: ValidationInfo): + # aliyun uses two URL formats, which may include a URL path + return validate_url_with_path(v, "https://tracing-analysis-dc-hz.aliyuncs.com") diff --git a/api/core/ops/aliyun_trace/data_exporter/__init__.py b/api/providers/trace/trace-aliyun/src/dify_trace_aliyun/data_exporter/__init__.py similarity index 100% rename from api/core/ops/aliyun_trace/data_exporter/__init__.py rename to api/providers/trace/trace-aliyun/src/dify_trace_aliyun/data_exporter/__init__.py diff --git a/api/core/ops/aliyun_trace/data_exporter/traceclient.py b/api/providers/trace/trace-aliyun/src/dify_trace_aliyun/data_exporter/traceclient.py similarity index 98% rename from api/core/ops/aliyun_trace/data_exporter/traceclient.py rename to api/providers/trace/trace-aliyun/src/dify_trace_aliyun/data_exporter/traceclient.py index 67d5163b0f..00aab6bf89 100644 --- a/api/core/ops/aliyun_trace/data_exporter/traceclient.py +++ b/api/providers/trace/trace-aliyun/src/dify_trace_aliyun/data_exporter/traceclient.py @@ -26,8 +26,8 @@ from opentelemetry.semconv.attributes import service_attributes from opentelemetry.trace import Link, SpanContext, TraceFlags from configs import dify_config -from core.ops.aliyun_trace.entities.aliyun_trace_entity import SpanData -from core.ops.aliyun_trace.entities.semconv import ACS_ARMS_SERVICE_FEATURE +from dify_trace_aliyun.entities.aliyun_trace_entity import SpanData +from dify_trace_aliyun.entities.semconv import ACS_ARMS_SERVICE_FEATURE INVALID_SPAN_ID: Final[int] = 0x0000000000000000 INVALID_TRACE_ID: Final[int] = 0x00000000000000000000000000000000 diff --git a/api/core/ops/aliyun_trace/entities/__init__.py b/api/providers/trace/trace-aliyun/src/dify_trace_aliyun/entities/__init__.py similarity index 100% rename from api/core/ops/aliyun_trace/entities/__init__.py rename to api/providers/trace/trace-aliyun/src/dify_trace_aliyun/entities/__init__.py diff --git a/api/core/ops/aliyun_trace/entities/aliyun_trace_entity.py b/api/providers/trace/trace-aliyun/src/dify_trace_aliyun/entities/aliyun_trace_entity.py similarity index 100% rename from api/core/ops/aliyun_trace/entities/aliyun_trace_entity.py rename to api/providers/trace/trace-aliyun/src/dify_trace_aliyun/entities/aliyun_trace_entity.py diff --git a/api/core/ops/aliyun_trace/entities/semconv.py b/api/providers/trace/trace-aliyun/src/dify_trace_aliyun/entities/semconv.py similarity index 100% rename from api/core/ops/aliyun_trace/entities/semconv.py rename to api/providers/trace/trace-aliyun/src/dify_trace_aliyun/entities/semconv.py diff --git a/api/core/ops/arize_phoenix_trace/__init__.py b/api/providers/trace/trace-aliyun/src/dify_trace_aliyun/py.typed similarity index 100% rename from api/core/ops/arize_phoenix_trace/__init__.py rename to api/providers/trace/trace-aliyun/src/dify_trace_aliyun/py.typed diff --git a/api/core/ops/aliyun_trace/utils.py b/api/providers/trace/trace-aliyun/src/dify_trace_aliyun/utils.py similarity index 97% rename from api/core/ops/aliyun_trace/utils.py rename to api/providers/trace/trace-aliyun/src/dify_trace_aliyun/utils.py index 2e02a186cc..5678c66adb 100644 --- a/api/core/ops/aliyun_trace/utils.py +++ b/api/providers/trace/trace-aliyun/src/dify_trace_aliyun/utils.py @@ -4,7 +4,8 @@ from typing import Any, TypedDict from opentelemetry.trace import Link, Status, StatusCode -from core.ops.aliyun_trace.entities.semconv import ( +from core.rag.models.document import Document +from dify_trace_aliyun.entities.semconv import ( GEN_AI_FRAMEWORK, GEN_AI_SESSION_ID, GEN_AI_SPAN_KIND, @@ -13,7 +14,6 @@ from core.ops.aliyun_trace.entities.semconv import ( OUTPUT_VALUE, GenAISpanKind, ) -from core.rag.models.document import Document from extensions.ext_database import db from graphon.entities import WorkflowNodeExecution from graphon.enums import WorkflowNodeExecutionStatus @@ -48,7 +48,7 @@ def get_workflow_node_status(node_execution: WorkflowNodeExecution) -> Status: def create_links_from_trace_id(trace_id: str | None) -> list[Link]: - from core.ops.aliyun_trace.data_exporter.traceclient import create_link + from dify_trace_aliyun.data_exporter.traceclient import create_link links = [] if trace_id: diff --git a/api/tests/unit_tests/core/ops/aliyun_trace/data_exporter/test_traceclient.py b/api/providers/trace/trace-aliyun/tests/unit_tests/aliyun_trace/data_exporter/test_traceclient.py similarity index 86% rename from api/tests/unit_tests/core/ops/aliyun_trace/data_exporter/test_traceclient.py rename to api/providers/trace/trace-aliyun/tests/unit_tests/aliyun_trace/data_exporter/test_traceclient.py index acb43d4036..286dda419c 100644 --- a/api/tests/unit_tests/core/ops/aliyun_trace/data_exporter/test_traceclient.py +++ b/api/providers/trace/trace-aliyun/tests/unit_tests/aliyun_trace/data_exporter/test_traceclient.py @@ -5,10 +5,7 @@ from unittest.mock import MagicMock, patch import httpx import pytest -from opentelemetry.sdk.trace import ReadableSpan -from opentelemetry.trace import SpanKind, Status, StatusCode - -from core.ops.aliyun_trace.data_exporter.traceclient import ( +from dify_trace_aliyun.data_exporter.traceclient import ( INVALID_SPAN_ID, SpanBuilder, TraceClient, @@ -20,7 +17,9 @@ from core.ops.aliyun_trace.data_exporter.traceclient import ( create_link, generate_span_id, ) -from core.ops.aliyun_trace.entities.aliyun_trace_entity import SpanData +from dify_trace_aliyun.entities.aliyun_trace_entity import SpanData +from opentelemetry.sdk.trace import ReadableSpan +from opentelemetry.trace import SpanKind, Status, StatusCode @pytest.fixture @@ -41,8 +40,8 @@ def trace_client_factory(): class TestTraceClient: - @patch("core.ops.aliyun_trace.data_exporter.traceclient.OTLPSpanExporter") - @patch("core.ops.aliyun_trace.data_exporter.traceclient.socket.gethostname") + @patch("dify_trace_aliyun.data_exporter.traceclient.OTLPSpanExporter") + @patch("dify_trace_aliyun.data_exporter.traceclient.socket.gethostname") def test_init(self, mock_gethostname, mock_exporter_class, trace_client_factory): mock_gethostname.return_value = "test-host" client = trace_client_factory(service_name="test-service", endpoint="http://test-endpoint") @@ -56,7 +55,7 @@ class TestTraceClient: client.shutdown() assert client.done is True - @patch("core.ops.aliyun_trace.data_exporter.traceclient.OTLPSpanExporter") + @patch("dify_trace_aliyun.data_exporter.traceclient.OTLPSpanExporter") def test_export(self, mock_exporter_class, trace_client_factory): mock_exporter = mock_exporter_class.return_value client = trace_client_factory(service_name="test-service", endpoint="http://test-endpoint") @@ -64,8 +63,8 @@ class TestTraceClient: client.export(spans) mock_exporter.export.assert_called_once_with(spans) - @patch("core.ops.aliyun_trace.data_exporter.traceclient.httpx.head") - @patch("core.ops.aliyun_trace.data_exporter.traceclient.OTLPSpanExporter") + @patch("dify_trace_aliyun.data_exporter.traceclient.httpx.head") + @patch("dify_trace_aliyun.data_exporter.traceclient.OTLPSpanExporter") def test_api_check_success(self, mock_exporter_class, mock_head, trace_client_factory): mock_response = MagicMock() mock_response.status_code = 405 @@ -74,8 +73,8 @@ class TestTraceClient: client = trace_client_factory(service_name="test-service", endpoint="http://test-endpoint") assert client.api_check() is True - @patch("core.ops.aliyun_trace.data_exporter.traceclient.httpx.head") - @patch("core.ops.aliyun_trace.data_exporter.traceclient.OTLPSpanExporter") + @patch("dify_trace_aliyun.data_exporter.traceclient.httpx.head") + @patch("dify_trace_aliyun.data_exporter.traceclient.OTLPSpanExporter") def test_api_check_failure_status(self, mock_exporter_class, mock_head, trace_client_factory): mock_response = MagicMock() mock_response.status_code = 500 @@ -84,8 +83,8 @@ class TestTraceClient: client = trace_client_factory(service_name="test-service", endpoint="http://test-endpoint") assert client.api_check() is False - @patch("core.ops.aliyun_trace.data_exporter.traceclient.httpx.head") - @patch("core.ops.aliyun_trace.data_exporter.traceclient.OTLPSpanExporter") + @patch("dify_trace_aliyun.data_exporter.traceclient.httpx.head") + @patch("dify_trace_aliyun.data_exporter.traceclient.OTLPSpanExporter") def test_api_check_exception(self, mock_exporter_class, mock_head, trace_client_factory): mock_head.side_effect = httpx.RequestError("Connection error") @@ -93,12 +92,12 @@ class TestTraceClient: with pytest.raises(ValueError, match="AliyunTrace API check failed: Connection error"): client.api_check() - @patch("core.ops.aliyun_trace.data_exporter.traceclient.OTLPSpanExporter") + @patch("dify_trace_aliyun.data_exporter.traceclient.OTLPSpanExporter") def test_get_project_url(self, mock_exporter_class, trace_client_factory): client = trace_client_factory(service_name="test-service", endpoint="http://test-endpoint") assert client.get_project_url() == "https://arms.console.aliyun.com/#/llm" - @patch("core.ops.aliyun_trace.data_exporter.traceclient.OTLPSpanExporter") + @patch("dify_trace_aliyun.data_exporter.traceclient.OTLPSpanExporter") def test_add_span(self, mock_exporter_class, trace_client_factory): client = trace_client_factory( service_name="test-service", @@ -134,8 +133,8 @@ class TestTraceClient: assert len(client.queue) == 2 mock_notify.assert_called_once() - @patch("core.ops.aliyun_trace.data_exporter.traceclient.OTLPSpanExporter") - @patch("core.ops.aliyun_trace.data_exporter.traceclient.logger") + @patch("dify_trace_aliyun.data_exporter.traceclient.OTLPSpanExporter") + @patch("dify_trace_aliyun.data_exporter.traceclient.logger") def test_add_span_queue_full(self, mock_logger, mock_exporter_class, trace_client_factory): client = trace_client_factory(service_name="test-service", endpoint="http://test-endpoint", max_queue_size=1) @@ -159,7 +158,7 @@ class TestTraceClient: assert len(client.queue) == 1 mock_logger.warning.assert_called_with("Queue is full, likely spans will be dropped.") - @patch("core.ops.aliyun_trace.data_exporter.traceclient.OTLPSpanExporter") + @patch("dify_trace_aliyun.data_exporter.traceclient.OTLPSpanExporter") def test_export_batch_error(self, mock_exporter_class, trace_client_factory): mock_exporter = mock_exporter_class.return_value mock_exporter.export.side_effect = Exception("Export failed") @@ -168,11 +167,11 @@ class TestTraceClient: mock_span = MagicMock(spec=ReadableSpan) client.queue.append(mock_span) - with patch("core.ops.aliyun_trace.data_exporter.traceclient.logger") as mock_logger: + with patch("dify_trace_aliyun.data_exporter.traceclient.logger") as mock_logger: client._export_batch() mock_logger.warning.assert_called() - @patch("core.ops.aliyun_trace.data_exporter.traceclient.OTLPSpanExporter") + @patch("dify_trace_aliyun.data_exporter.traceclient.OTLPSpanExporter") def test_worker_loop(self, mock_exporter_class, trace_client_factory): # We need to test the wait timeout in _worker # But _worker runs in a thread. Let's mock condition.wait. @@ -189,7 +188,7 @@ class TestTraceClient: # mock_wait might have been called assert mock_wait.called or client.done - @patch("core.ops.aliyun_trace.data_exporter.traceclient.OTLPSpanExporter") + @patch("dify_trace_aliyun.data_exporter.traceclient.OTLPSpanExporter") def test_shutdown_flushes(self, mock_exporter_class, trace_client_factory): mock_exporter = mock_exporter_class.return_value client = trace_client_factory(service_name="test-service", endpoint="http://test-endpoint") @@ -268,7 +267,7 @@ def test_generate_span_id(): assert span_id != INVALID_SPAN_ID # Test retry loop - with patch("core.ops.aliyun_trace.data_exporter.traceclient.random.getrandbits") as mock_rand: + with patch("dify_trace_aliyun.data_exporter.traceclient.random.getrandbits") as mock_rand: mock_rand.side_effect = [INVALID_SPAN_ID, 999] span_id = generate_span_id() assert span_id == 999 @@ -290,7 +289,7 @@ def test_convert_to_trace_id(): def test_convert_string_to_id(): assert convert_string_to_id("test") > 0 # Test with None string - with patch("core.ops.aliyun_trace.data_exporter.traceclient.generate_span_id") as mock_gen: + with patch("dify_trace_aliyun.data_exporter.traceclient.generate_span_id") as mock_gen: mock_gen.return_value = 12345 assert convert_string_to_id(None) == 12345 diff --git a/api/tests/unit_tests/core/ops/aliyun_trace/entities/test_aliyun_trace_entity.py b/api/providers/trace/trace-aliyun/tests/unit_tests/aliyun_trace/entities/test_aliyun_trace_entity.py similarity index 97% rename from api/tests/unit_tests/core/ops/aliyun_trace/entities/test_aliyun_trace_entity.py rename to api/providers/trace/trace-aliyun/tests/unit_tests/aliyun_trace/entities/test_aliyun_trace_entity.py index 2fcb927e0c..38d33dd21b 100644 --- a/api/tests/unit_tests/core/ops/aliyun_trace/entities/test_aliyun_trace_entity.py +++ b/api/providers/trace/trace-aliyun/tests/unit_tests/aliyun_trace/entities/test_aliyun_trace_entity.py @@ -1,11 +1,10 @@ import pytest +from dify_trace_aliyun.entities.aliyun_trace_entity import SpanData, TraceMetadata from opentelemetry import trace as trace_api from opentelemetry.sdk.trace import Event from opentelemetry.trace import SpanKind, Status, StatusCode from pydantic import ValidationError -from core.ops.aliyun_trace.entities.aliyun_trace_entity import SpanData, TraceMetadata - class TestTraceMetadata: def test_trace_metadata_init(self): diff --git a/api/tests/unit_tests/core/ops/aliyun_trace/entities/test_semconv.py b/api/providers/trace/trace-aliyun/tests/unit_tests/aliyun_trace/entities/test_semconv.py similarity index 97% rename from api/tests/unit_tests/core/ops/aliyun_trace/entities/test_semconv.py rename to api/providers/trace/trace-aliyun/tests/unit_tests/aliyun_trace/entities/test_semconv.py index 3961555b9a..9cab40748f 100644 --- a/api/tests/unit_tests/core/ops/aliyun_trace/entities/test_semconv.py +++ b/api/providers/trace/trace-aliyun/tests/unit_tests/aliyun_trace/entities/test_semconv.py @@ -1,4 +1,4 @@ -from core.ops.aliyun_trace.entities.semconv import ( +from dify_trace_aliyun.entities.semconv import ( ACS_ARMS_SERVICE_FEATURE, GEN_AI_COMPLETION, GEN_AI_FRAMEWORK, diff --git a/api/tests/unit_tests/core/ops/aliyun_trace/test_aliyun_trace.py b/api/providers/trace/trace-aliyun/tests/unit_tests/aliyun_trace/test_aliyun_trace.py similarity index 99% rename from api/tests/unit_tests/core/ops/aliyun_trace/test_aliyun_trace.py rename to api/providers/trace/trace-aliyun/tests/unit_tests/aliyun_trace/test_aliyun_trace.py index c2324fdec4..c1b11c9186 100644 --- a/api/tests/unit_tests/core/ops/aliyun_trace/test_aliyun_trace.py +++ b/api/providers/trace/trace-aliyun/tests/unit_tests/aliyun_trace/test_aliyun_trace.py @@ -4,12 +4,11 @@ from datetime import UTC, datetime from types import SimpleNamespace from unittest.mock import MagicMock +import dify_trace_aliyun.aliyun_trace as aliyun_trace_module import pytest -from opentelemetry.trace import Link, SpanContext, SpanKind, Status, StatusCode, TraceFlags - -import core.ops.aliyun_trace.aliyun_trace as aliyun_trace_module -from core.ops.aliyun_trace.aliyun_trace import AliyunDataTrace -from core.ops.aliyun_trace.entities.semconv import ( +from dify_trace_aliyun.aliyun_trace import AliyunDataTrace +from dify_trace_aliyun.config import AliyunConfig +from dify_trace_aliyun.entities.semconv import ( GEN_AI_COMPLETION, GEN_AI_INPUT_MESSAGE, GEN_AI_OUTPUT_MESSAGE, @@ -24,7 +23,8 @@ from core.ops.aliyun_trace.entities.semconv import ( TOOL_PARAMETERS, GenAISpanKind, ) -from core.ops.entities.config_entity import AliyunConfig +from opentelemetry.trace import Link, SpanContext, SpanKind, Status, StatusCode, TraceFlags + from core.ops.entities.trace_entity import ( DatasetRetrievalTraceInfo, GenerateNameTraceInfo, diff --git a/api/tests/unit_tests/core/ops/aliyun_trace/test_aliyun_trace_utils.py b/api/providers/trace/trace-aliyun/tests/unit_tests/aliyun_trace/test_aliyun_trace_utils.py similarity index 95% rename from api/tests/unit_tests/core/ops/aliyun_trace/test_aliyun_trace_utils.py rename to api/providers/trace/trace-aliyun/tests/unit_tests/aliyun_trace/test_aliyun_trace_utils.py index e4d8f2d5ea..a9e7b80c2a 100644 --- a/api/tests/unit_tests/core/ops/aliyun_trace/test_aliyun_trace_utils.py +++ b/api/providers/trace/trace-aliyun/tests/unit_tests/aliyun_trace/test_aliyun_trace_utils.py @@ -1,9 +1,7 @@ import json from unittest.mock import MagicMock -from opentelemetry.trace import Link, StatusCode - -from core.ops.aliyun_trace.entities.semconv import ( +from dify_trace_aliyun.entities.semconv import ( GEN_AI_FRAMEWORK, GEN_AI_SESSION_ID, GEN_AI_SPAN_KIND, @@ -11,7 +9,7 @@ from core.ops.aliyun_trace.entities.semconv import ( INPUT_VALUE, OUTPUT_VALUE, ) -from core.ops.aliyun_trace.utils import ( +from dify_trace_aliyun.utils import ( create_common_span_attributes, create_links_from_trace_id, create_status_from_error, @@ -23,6 +21,8 @@ from core.ops.aliyun_trace.utils import ( get_workflow_node_status, serialize_json_data, ) +from opentelemetry.trace import Link, StatusCode + from core.rag.models.document import Document from graphon.entities import WorkflowNodeExecution from graphon.enums import WorkflowNodeExecutionStatus @@ -48,7 +48,7 @@ def test_get_user_id_from_message_data_with_end_user(monkeypatch): mock_session = MagicMock() mock_session.get.return_value = end_user_data - from core.ops.aliyun_trace.utils import db + from dify_trace_aliyun.utils import db monkeypatch.setattr(db, "session", mock_session) @@ -63,7 +63,7 @@ def test_get_user_id_from_message_data_end_user_not_found(monkeypatch): mock_session = MagicMock() mock_session.get.return_value = None - from core.ops.aliyun_trace.utils import db + from dify_trace_aliyun.utils import db monkeypatch.setattr(db, "session", mock_session) @@ -112,9 +112,9 @@ def test_get_workflow_node_status(): def test_create_links_from_trace_id(monkeypatch): # Mock create_link mock_link = MagicMock(spec=Link) - import core.ops.aliyun_trace.data_exporter.traceclient + import dify_trace_aliyun.data_exporter.traceclient - monkeypatch.setattr(core.ops.aliyun_trace.data_exporter.traceclient, "create_link", lambda trace_id_str: mock_link) + monkeypatch.setattr(dify_trace_aliyun.data_exporter.traceclient, "create_link", lambda trace_id_str: mock_link) # Trace ID None assert create_links_from_trace_id(None) == [] diff --git a/api/providers/trace/trace-aliyun/tests/unit_tests/test_config_entity.py b/api/providers/trace/trace-aliyun/tests/unit_tests/test_config_entity.py new file mode 100644 index 0000000000..1b24ee7421 --- /dev/null +++ b/api/providers/trace/trace-aliyun/tests/unit_tests/test_config_entity.py @@ -0,0 +1,85 @@ +import pytest +from dify_trace_aliyun.config import AliyunConfig +from pydantic import ValidationError + + +class TestAliyunConfig: + """Test cases for AliyunConfig""" + + def test_valid_config(self): + """Test valid Aliyun configuration""" + config = AliyunConfig( + app_name="test_app", + license_key="test_license_key", + endpoint="https://custom.tracing-analysis-dc-hz.aliyuncs.com", + ) + assert config.app_name == "test_app" + assert config.license_key == "test_license_key" + assert config.endpoint == "https://custom.tracing-analysis-dc-hz.aliyuncs.com" + + def test_default_values(self): + """Test default values are set correctly""" + config = AliyunConfig(license_key="test_license", endpoint="https://tracing-analysis-dc-hz.aliyuncs.com") + assert config.app_name == "dify_app" + + def test_missing_required_fields(self): + """Test that required fields are enforced""" + with pytest.raises(ValidationError): + AliyunConfig() + + with pytest.raises(ValidationError): + AliyunConfig(license_key="test_license") + + with pytest.raises(ValidationError): + AliyunConfig(endpoint="https://tracing-analysis-dc-hz.aliyuncs.com") + + def test_app_name_validation_empty(self): + """Test app_name validation with empty value""" + config = AliyunConfig( + license_key="test_license", endpoint="https://tracing-analysis-dc-hz.aliyuncs.com", app_name="" + ) + assert config.app_name == "dify_app" + + def test_endpoint_validation_empty(self): + """Test endpoint validation with empty value""" + config = AliyunConfig(license_key="test_license", endpoint="") + assert config.endpoint == "https://tracing-analysis-dc-hz.aliyuncs.com" + + def test_endpoint_validation_with_path(self): + """Test endpoint validation preserves path for Aliyun endpoints""" + config = AliyunConfig( + license_key="test_license", endpoint="https://tracing-analysis-dc-hz.aliyuncs.com/api/v1/traces" + ) + assert config.endpoint == "https://tracing-analysis-dc-hz.aliyuncs.com/api/v1/traces" + + def test_endpoint_validation_invalid_scheme(self): + """Test endpoint validation rejects invalid schemes""" + with pytest.raises(ValidationError, match="URL must start with https:// or http://"): + AliyunConfig(license_key="test_license", endpoint="ftp://invalid.tracing-analysis-dc-hz.aliyuncs.com") + + def test_endpoint_validation_no_scheme(self): + """Test endpoint validation rejects URLs without scheme""" + with pytest.raises(ValidationError, match="URL must start with https:// or http://"): + AliyunConfig(license_key="test_license", endpoint="invalid.tracing-analysis-dc-hz.aliyuncs.com") + + def test_license_key_required(self): + """Test that license_key is required and cannot be empty""" + with pytest.raises(ValidationError): + AliyunConfig(license_key="", endpoint="https://tracing-analysis-dc-hz.aliyuncs.com") + + def test_valid_endpoint_format_examples(self): + """Test valid endpoint format examples from comments""" + valid_endpoints = [ + # cms2.0 public endpoint + "https://proj-xtrace-123456-cn-heyuan.cn-heyuan.log.aliyuncs.com/apm/trace/opentelemetry", + # cms2.0 intranet endpoint + "https://proj-xtrace-123456-cn-heyuan.cn-heyuan-intranet.log.aliyuncs.com/apm/trace/opentelemetry", + # xtrace public endpoint + "http://tracing-cn-heyuan.arms.aliyuncs.com", + # xtrace intranet endpoint + "http://tracing-cn-heyuan-internal.arms.aliyuncs.com", + ] + + for endpoint in valid_endpoints: + config = AliyunConfig(license_key="test_license", endpoint=endpoint) + assert config.endpoint == endpoint diff --git a/api/providers/trace/trace-arize-phoenix/pyproject.toml b/api/providers/trace/trace-arize-phoenix/pyproject.toml new file mode 100644 index 0000000000..9e756944c9 --- /dev/null +++ b/api/providers/trace/trace-arize-phoenix/pyproject.toml @@ -0,0 +1,10 @@ +[project] +name = "dify-trace-arize-phoenix" +version = "0.0.1" +dependencies = [ + "arize-phoenix-otel~=0.15.0", +] +description = "Dify ops tracing provider (Arize / Phoenix)." + +[tool.setuptools.packages.find] +where = ["src"] diff --git a/api/core/ops/langfuse_trace/__init__.py b/api/providers/trace/trace-arize-phoenix/src/dify_trace_arize_phoenix/__init__.py similarity index 100% rename from api/core/ops/langfuse_trace/__init__.py rename to api/providers/trace/trace-arize-phoenix/src/dify_trace_arize_phoenix/__init__.py diff --git a/api/core/ops/arize_phoenix_trace/arize_phoenix_trace.py b/api/providers/trace/trace-arize-phoenix/src/dify_trace_arize_phoenix/arize_phoenix_trace.py similarity index 99% rename from api/core/ops/arize_phoenix_trace/arize_phoenix_trace.py rename to api/providers/trace/trace-arize-phoenix/src/dify_trace_arize_phoenix/arize_phoenix_trace.py index 78516e1a22..96df49ed0e 100644 --- a/api/core/ops/arize_phoenix_trace/arize_phoenix_trace.py +++ b/api/providers/trace/trace-arize-phoenix/src/dify_trace_arize_phoenix/arize_phoenix_trace.py @@ -25,7 +25,6 @@ from opentelemetry.util.types import AttributeValue from sqlalchemy.orm import sessionmaker from core.ops.base_trace_instance import BaseTraceInstance -from core.ops.entities.config_entity import ArizeConfig, PhoenixConfig from core.ops.entities.trace_entity import ( BaseTraceInfo, DatasetRetrievalTraceInfo, @@ -39,6 +38,7 @@ from core.ops.entities.trace_entity import ( ) from core.ops.utils import JSON_DICT_ADAPTER from core.repositories import DifyCoreRepositoryFactory +from dify_trace_arize_phoenix.config import ArizeConfig, PhoenixConfig from extensions.ext_database import db from graphon.enums import WorkflowNodeExecutionStatus from models.model import EndUser, MessageFile diff --git a/api/providers/trace/trace-arize-phoenix/src/dify_trace_arize_phoenix/config.py b/api/providers/trace/trace-arize-phoenix/src/dify_trace_arize_phoenix/config.py new file mode 100644 index 0000000000..6eac5b30d2 --- /dev/null +++ b/api/providers/trace/trace-arize-phoenix/src/dify_trace_arize_phoenix/config.py @@ -0,0 +1,45 @@ +from pydantic import ValidationInfo, field_validator + +from core.ops.entities.config_entity import BaseTracingConfig +from core.ops.utils import validate_url_with_path + + +class ArizeConfig(BaseTracingConfig): + """ + Model class for Arize tracing config. + """ + + api_key: str | None = None + space_id: str | None = None + project: str | None = None + endpoint: str = "https://otlp.arize.com" + + @field_validator("project") + @classmethod + def project_validator(cls, v, info: ValidationInfo): + return cls.validate_project_field(v, "default") + + @field_validator("endpoint") + @classmethod + def endpoint_validator(cls, v, info: ValidationInfo): + return cls.validate_endpoint_url(v, "https://otlp.arize.com") + + +class PhoenixConfig(BaseTracingConfig): + """ + Model class for Phoenix tracing config. + """ + + api_key: str | None = None + project: str | None = None + endpoint: str = "https://app.phoenix.arize.com" + + @field_validator("project") + @classmethod + def project_validator(cls, v, info: ValidationInfo): + return cls.validate_project_field(v, "default") + + @field_validator("endpoint") + @classmethod + def endpoint_validator(cls, v, info: ValidationInfo): + return validate_url_with_path(v, "https://app.phoenix.arize.com") diff --git a/api/core/ops/langfuse_trace/entities/__init__.py b/api/providers/trace/trace-arize-phoenix/src/dify_trace_arize_phoenix/py.typed similarity index 100% rename from api/core/ops/langfuse_trace/entities/__init__.py rename to api/providers/trace/trace-arize-phoenix/src/dify_trace_arize_phoenix/py.typed diff --git a/api/tests/unit_tests/core/ops/arize_phoenix_trace/test_arize_phoenix_trace.py b/api/providers/trace/trace-arize-phoenix/tests/unit_tests/arize_phoenix_trace/test_arize_phoenix_trace.py similarity index 91% rename from api/tests/unit_tests/core/ops/arize_phoenix_trace/test_arize_phoenix_trace.py rename to api/providers/trace/trace-arize-phoenix/tests/unit_tests/arize_phoenix_trace/test_arize_phoenix_trace.py index 4ce9e22fd7..b0691a87ea 100644 --- a/api/tests/unit_tests/core/ops/arize_phoenix_trace/test_arize_phoenix_trace.py +++ b/api/providers/trace/trace-arize-phoenix/tests/unit_tests/arize_phoenix_trace/test_arize_phoenix_trace.py @@ -2,11 +2,7 @@ from datetime import UTC, datetime, timedelta from unittest.mock import MagicMock, patch import pytest -from opentelemetry.sdk.trace import Tracer -from opentelemetry.semconv.trace import SpanAttributes as OTELSpanAttributes -from opentelemetry.trace import StatusCode - -from core.ops.arize_phoenix_trace.arize_phoenix_trace import ( +from dify_trace_arize_phoenix.arize_phoenix_trace import ( ArizePhoenixDataTrace, datetime_to_nanos, error_to_string, @@ -15,7 +11,11 @@ from core.ops.arize_phoenix_trace.arize_phoenix_trace import ( setup_tracer, wrap_span_metadata, ) -from core.ops.entities.config_entity import ArizeConfig, PhoenixConfig +from dify_trace_arize_phoenix.config import ArizeConfig, PhoenixConfig +from opentelemetry.sdk.trace import Tracer +from opentelemetry.semconv.trace import SpanAttributes as OTELSpanAttributes +from opentelemetry.trace import StatusCode + from core.ops.entities.trace_entity import ( DatasetRetrievalTraceInfo, GenerateNameTraceInfo, @@ -80,7 +80,7 @@ def test_datetime_to_nanos(): expected = int(dt.timestamp() * 1_000_000_000) assert datetime_to_nanos(dt) == expected - with patch("core.ops.arize_phoenix_trace.arize_phoenix_trace.datetime") as mock_dt: + with patch("dify_trace_arize_phoenix.arize_phoenix_trace.datetime") as mock_dt: mock_now = MagicMock() mock_now.timestamp.return_value = 1704110400.0 mock_dt.now.return_value = mock_now @@ -142,8 +142,8 @@ def test_wrap_span_metadata(): assert res == {"a": 1, "b": 2, "created_from": "Dify"} -@patch("core.ops.arize_phoenix_trace.arize_phoenix_trace.GrpcOTLPSpanExporter") -@patch("core.ops.arize_phoenix_trace.arize_phoenix_trace.trace_sdk.TracerProvider") +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.GrpcOTLPSpanExporter") +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.trace_sdk.TracerProvider") def test_setup_tracer_arize(mock_provider, mock_exporter): config = ArizeConfig(endpoint="http://a.com", api_key="k", space_id="s", project="p") setup_tracer(config) @@ -151,8 +151,8 @@ def test_setup_tracer_arize(mock_provider, mock_exporter): assert mock_exporter.call_args[1]["endpoint"] == "http://a.com/v1" -@patch("core.ops.arize_phoenix_trace.arize_phoenix_trace.HttpOTLPSpanExporter") -@patch("core.ops.arize_phoenix_trace.arize_phoenix_trace.trace_sdk.TracerProvider") +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.HttpOTLPSpanExporter") +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.trace_sdk.TracerProvider") def test_setup_tracer_phoenix(mock_provider, mock_exporter): config = PhoenixConfig(endpoint="http://p.com", project="p") setup_tracer(config) @@ -162,7 +162,7 @@ def test_setup_tracer_phoenix(mock_provider, mock_exporter): def test_setup_tracer_exception(): config = ArizeConfig(endpoint="http://a.com", project="p") - with patch("core.ops.arize_phoenix_trace.arize_phoenix_trace.urlparse", side_effect=Exception("boom")): + with patch("dify_trace_arize_phoenix.arize_phoenix_trace.urlparse", side_effect=Exception("boom")): with pytest.raises(Exception, match="boom"): setup_tracer(config) @@ -172,7 +172,7 @@ def test_setup_tracer_exception(): @pytest.fixture def trace_instance(): - with patch("core.ops.arize_phoenix_trace.arize_phoenix_trace.setup_tracer") as mock_setup: + with patch("dify_trace_arize_phoenix.arize_phoenix_trace.setup_tracer") as mock_setup: mock_tracer = MagicMock(spec=Tracer) mock_processor = MagicMock() mock_setup.return_value = (mock_tracer, mock_processor) @@ -228,9 +228,9 @@ def test_trace_exception(trace_instance): trace_instance.trace(_make_workflow_info()) -@patch("core.ops.arize_phoenix_trace.arize_phoenix_trace.sessionmaker") -@patch("core.ops.arize_phoenix_trace.arize_phoenix_trace.DifyCoreRepositoryFactory") -@patch("core.ops.arize_phoenix_trace.arize_phoenix_trace.db") +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.sessionmaker") +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.DifyCoreRepositoryFactory") +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.db") def test_workflow_trace_full(mock_db, mock_repo_factory, mock_sessionmaker, trace_instance): mock_db.engine = MagicMock() info = _make_workflow_info() @@ -262,7 +262,7 @@ def test_workflow_trace_full(mock_db, mock_repo_factory, mock_sessionmaker, trac assert trace_instance.tracer.start_span.call_count >= 2 -@patch("core.ops.arize_phoenix_trace.arize_phoenix_trace.db") +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.db") def test_workflow_trace_no_app_id(mock_db, trace_instance): mock_db.engine = MagicMock() info = _make_workflow_info() @@ -271,7 +271,7 @@ def test_workflow_trace_no_app_id(mock_db, trace_instance): trace_instance.workflow_trace(info) -@patch("core.ops.arize_phoenix_trace.arize_phoenix_trace.db") +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.db") def test_message_trace_success(mock_db, trace_instance): mock_db.engine = MagicMock() info = _make_message_info() @@ -291,7 +291,7 @@ def test_message_trace_success(mock_db, trace_instance): assert trace_instance.tracer.start_span.call_count >= 1 -@patch("core.ops.arize_phoenix_trace.arize_phoenix_trace.db") +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.db") def test_message_trace_with_error(mock_db, trace_instance): mock_db.engine = MagicMock() info = _make_message_info() diff --git a/api/tests/unit_tests/core/ops/test_arize_phoenix_trace.py b/api/providers/trace/trace-arize-phoenix/tests/unit_tests/test_arize_phoenix_trace.py similarity index 94% rename from api/tests/unit_tests/core/ops/test_arize_phoenix_trace.py rename to api/providers/trace/trace-arize-phoenix/tests/unit_tests/test_arize_phoenix_trace.py index 4b925390d9..a01c63ae61 100644 --- a/api/tests/unit_tests/core/ops/test_arize_phoenix_trace.py +++ b/api/providers/trace/trace-arize-phoenix/tests/unit_tests/test_arize_phoenix_trace.py @@ -1,6 +1,6 @@ +from dify_trace_arize_phoenix.arize_phoenix_trace import _NODE_TYPE_TO_SPAN_KIND, _get_node_span_kind from openinference.semconv.trace import OpenInferenceSpanKindValues -from core.ops.arize_phoenix_trace.arize_phoenix_trace import _NODE_TYPE_TO_SPAN_KIND, _get_node_span_kind from graphon.enums import BUILT_IN_NODE_TYPES, BuiltinNodeTypes diff --git a/api/providers/trace/trace-arize-phoenix/tests/unit_tests/test_config_entity.py b/api/providers/trace/trace-arize-phoenix/tests/unit_tests/test_config_entity.py new file mode 100644 index 0000000000..11e951c3b1 --- /dev/null +++ b/api/providers/trace/trace-arize-phoenix/tests/unit_tests/test_config_entity.py @@ -0,0 +1,88 @@ +import pytest +from dify_trace_arize_phoenix.config import ArizeConfig, PhoenixConfig +from pydantic import ValidationError + + +class TestArizeConfig: + """Test cases for ArizeConfig""" + + def test_valid_config(self): + """Test valid Arize configuration""" + config = ArizeConfig( + api_key="test_key", space_id="test_space", project="test_project", endpoint="https://custom.arize.com" + ) + assert config.api_key == "test_key" + assert config.space_id == "test_space" + assert config.project == "test_project" + assert config.endpoint == "https://custom.arize.com" + + def test_default_values(self): + """Test default values are set correctly""" + config = ArizeConfig() + assert config.api_key is None + assert config.space_id is None + assert config.project is None + assert config.endpoint == "https://otlp.arize.com" + + def test_project_validation_empty(self): + """Test project validation with empty value""" + config = ArizeConfig(project="") + assert config.project == "default" + + def test_project_validation_none(self): + """Test project validation with None value""" + config = ArizeConfig(project=None) + assert config.project == "default" + + def test_endpoint_validation_empty(self): + """Test endpoint validation with empty value""" + config = ArizeConfig(endpoint="") + assert config.endpoint == "https://otlp.arize.com" + + def test_endpoint_validation_with_path(self): + """Test endpoint validation normalizes URL by removing path""" + config = ArizeConfig(endpoint="https://custom.arize.com/api/v1") + assert config.endpoint == "https://custom.arize.com" + + def test_endpoint_validation_invalid_scheme(self): + """Test endpoint validation rejects invalid schemes""" + with pytest.raises(ValidationError, match="URL scheme must be one of"): + ArizeConfig(endpoint="ftp://invalid.com") + + def test_endpoint_validation_no_scheme(self): + """Test endpoint validation rejects URLs without scheme""" + with pytest.raises(ValidationError, match="URL scheme must be one of"): + ArizeConfig(endpoint="invalid.com") + + +class TestPhoenixConfig: + """Test cases for PhoenixConfig""" + + def test_valid_config(self): + """Test valid Phoenix configuration""" + config = PhoenixConfig(api_key="test_key", project="test_project", endpoint="https://custom.phoenix.com") + assert config.api_key == "test_key" + assert config.project == "test_project" + assert config.endpoint == "https://custom.phoenix.com" + + def test_default_values(self): + """Test default values are set correctly""" + config = PhoenixConfig() + assert config.api_key is None + assert config.project is None + assert config.endpoint == "https://app.phoenix.arize.com" + + def test_project_validation_empty(self): + """Test project validation with empty value""" + config = PhoenixConfig(project="") + assert config.project == "default" + + def test_endpoint_validation_with_path(self): + """Test endpoint validation with path""" + config = PhoenixConfig(endpoint="https://app.phoenix.arize.com/s/dify-integration") + assert config.endpoint == "https://app.phoenix.arize.com/s/dify-integration" + + def test_endpoint_validation_without_path(self): + """Test endpoint validation without path""" + config = PhoenixConfig(endpoint="https://app.phoenix.arize.com") + assert config.endpoint == "https://app.phoenix.arize.com" diff --git a/api/providers/trace/trace-langfuse/pyproject.toml b/api/providers/trace/trace-langfuse/pyproject.toml new file mode 100644 index 0000000000..27d2273a69 --- /dev/null +++ b/api/providers/trace/trace-langfuse/pyproject.toml @@ -0,0 +1,10 @@ +[project] +name = "dify-trace-langfuse" +version = "0.0.1" +dependencies = [ + "langfuse>=4.2.0,<5.0.0", +] +description = "Dify ops tracing provider (Langfuse)." + +[tool.setuptools.packages.find] +where = ["src"] diff --git a/api/core/ops/langsmith_trace/__init__.py b/api/providers/trace/trace-langfuse/src/dify_trace_langfuse/__init__.py similarity index 100% rename from api/core/ops/langsmith_trace/__init__.py rename to api/providers/trace/trace-langfuse/src/dify_trace_langfuse/__init__.py diff --git a/api/providers/trace/trace-langfuse/src/dify_trace_langfuse/config.py b/api/providers/trace/trace-langfuse/src/dify_trace_langfuse/config.py new file mode 100644 index 0000000000..90d1a2846b --- /dev/null +++ b/api/providers/trace/trace-langfuse/src/dify_trace_langfuse/config.py @@ -0,0 +1,19 @@ +from pydantic import ValidationInfo, field_validator + +from core.ops.entities.config_entity import BaseTracingConfig +from core.ops.utils import validate_url_with_path + + +class LangfuseConfig(BaseTracingConfig): + """ + Model class for Langfuse tracing config. + """ + + public_key: str + secret_key: str + host: str = "https://api.langfuse.com" + + @field_validator("host") + @classmethod + def host_validator(cls, v, info: ValidationInfo): + return validate_url_with_path(v, "https://api.langfuse.com") diff --git a/api/core/ops/langsmith_trace/entities/__init__.py b/api/providers/trace/trace-langfuse/src/dify_trace_langfuse/entities/__init__.py similarity index 100% rename from api/core/ops/langsmith_trace/entities/__init__.py rename to api/providers/trace/trace-langfuse/src/dify_trace_langfuse/entities/__init__.py diff --git a/api/core/ops/langfuse_trace/entities/langfuse_trace_entity.py b/api/providers/trace/trace-langfuse/src/dify_trace_langfuse/entities/langfuse_trace_entity.py similarity index 100% rename from api/core/ops/langfuse_trace/entities/langfuse_trace_entity.py rename to api/providers/trace/trace-langfuse/src/dify_trace_langfuse/entities/langfuse_trace_entity.py diff --git a/api/core/ops/langfuse_trace/langfuse_trace.py b/api/providers/trace/trace-langfuse/src/dify_trace_langfuse/langfuse_trace.py similarity index 99% rename from api/core/ops/langfuse_trace/langfuse_trace.py rename to api/providers/trace/trace-langfuse/src/dify_trace_langfuse/langfuse_trace.py index 7eacc2be46..68881378a7 100644 --- a/api/core/ops/langfuse_trace/langfuse_trace.py +++ b/api/providers/trace/trace-langfuse/src/dify_trace_langfuse/langfuse_trace.py @@ -16,7 +16,6 @@ from langfuse.api.commons.types.usage import Usage from sqlalchemy.orm import sessionmaker from core.ops.base_trace_instance import BaseTraceInstance -from core.ops.entities.config_entity import LangfuseConfig from core.ops.entities.trace_entity import ( BaseTraceInfo, DatasetRetrievalTraceInfo, @@ -28,7 +27,10 @@ from core.ops.entities.trace_entity import ( TraceTaskName, WorkflowTraceInfo, ) -from core.ops.langfuse_trace.entities.langfuse_trace_entity import ( +from core.ops.utils import filter_none_values +from core.repositories import DifyCoreRepositoryFactory +from dify_trace_langfuse.config import LangfuseConfig +from dify_trace_langfuse.entities.langfuse_trace_entity import ( GenerationUsage, LangfuseGeneration, LangfuseSpan, @@ -36,8 +38,6 @@ from core.ops.langfuse_trace.entities.langfuse_trace_entity import ( LevelEnum, UnitEnum, ) -from core.ops.utils import filter_none_values -from core.repositories import DifyCoreRepositoryFactory from extensions.ext_database import db from graphon.enums import BuiltinNodeTypes from models import EndUser, WorkflowNodeExecutionTriggeredFrom diff --git a/api/core/ops/mlflow_trace/__init__.py b/api/providers/trace/trace-langfuse/src/dify_trace_langfuse/py.typed similarity index 100% rename from api/core/ops/mlflow_trace/__init__.py rename to api/providers/trace/trace-langfuse/src/dify_trace_langfuse/py.typed diff --git a/api/tests/unit_tests/core/ops/langfuse_trace/test_langfuse_trace.py b/api/providers/trace/trace-langfuse/tests/unit_tests/langfuse_trace/test_langfuse_trace.py similarity index 93% rename from api/tests/unit_tests/core/ops/langfuse_trace/test_langfuse_trace.py rename to api/providers/trace/trace-langfuse/tests/unit_tests/langfuse_trace/test_langfuse_trace.py index a0bcc92795..952f10c34f 100644 --- a/api/tests/unit_tests/core/ops/langfuse_trace/test_langfuse_trace.py +++ b/api/providers/trace/trace-langfuse/tests/unit_tests/langfuse_trace/test_langfuse_trace.py @@ -5,8 +5,16 @@ from types import SimpleNamespace from unittest.mock import MagicMock import pytest +from dify_trace_langfuse.config import LangfuseConfig +from dify_trace_langfuse.entities.langfuse_trace_entity import ( + LangfuseGeneration, + LangfuseSpan, + LangfuseTrace, + LevelEnum, + UnitEnum, +) +from dify_trace_langfuse.langfuse_trace import LangFuseDataTrace -from core.ops.entities.config_entity import LangfuseConfig from core.ops.entities.trace_entity import ( DatasetRetrievalTraceInfo, GenerateNameTraceInfo, @@ -17,14 +25,6 @@ from core.ops.entities.trace_entity import ( TraceTaskName, WorkflowTraceInfo, ) -from core.ops.langfuse_trace.entities.langfuse_trace_entity import ( - LangfuseGeneration, - LangfuseSpan, - LangfuseTrace, - LevelEnum, - UnitEnum, -) -from core.ops.langfuse_trace.langfuse_trace import LangFuseDataTrace from graphon.enums import BuiltinNodeTypes from models import EndUser from models.enums import MessageStatus @@ -43,7 +43,7 @@ def langfuse_config(): def trace_instance(langfuse_config, monkeypatch): # Mock Langfuse client to avoid network calls mock_client = MagicMock() - monkeypatch.setattr("core.ops.langfuse_trace.langfuse_trace.Langfuse", lambda **kwargs: mock_client) + monkeypatch.setattr("dify_trace_langfuse.langfuse_trace.Langfuse", lambda **kwargs: mock_client) instance = LangFuseDataTrace(langfuse_config) return instance @@ -51,7 +51,7 @@ def trace_instance(langfuse_config, monkeypatch): def test_init(langfuse_config, monkeypatch): mock_langfuse = MagicMock() - monkeypatch.setattr("core.ops.langfuse_trace.langfuse_trace.Langfuse", mock_langfuse) + monkeypatch.setattr("dify_trace_langfuse.langfuse_trace.Langfuse", mock_langfuse) monkeypatch.setenv("FILES_URL", "http://test.url") instance = LangFuseDataTrace(langfuse_config) @@ -140,8 +140,8 @@ def test_workflow_trace_with_message_id(trace_instance, monkeypatch): # Mock DB and Repositories mock_session = MagicMock() - monkeypatch.setattr("core.ops.langfuse_trace.langfuse_trace.sessionmaker", lambda bind: lambda: mock_session) - monkeypatch.setattr("core.ops.langfuse_trace.langfuse_trace.db", MagicMock(engine="engine")) + monkeypatch.setattr("dify_trace_langfuse.langfuse_trace.sessionmaker", lambda bind: lambda: mock_session) + monkeypatch.setattr("dify_trace_langfuse.langfuse_trace.db", MagicMock(engine="engine")) # Mock node executions node_llm = MagicMock() @@ -178,7 +178,7 @@ def test_workflow_trace_with_message_id(trace_instance, monkeypatch): mock_factory = MagicMock() mock_factory.create_workflow_node_execution_repository.return_value = repo - monkeypatch.setattr("core.ops.langfuse_trace.langfuse_trace.DifyCoreRepositoryFactory", mock_factory) + monkeypatch.setattr("dify_trace_langfuse.langfuse_trace.DifyCoreRepositoryFactory", mock_factory) monkeypatch.setattr(trace_instance, "get_service_account_with_tenant", lambda app_id: MagicMock()) @@ -241,13 +241,13 @@ def test_workflow_trace_no_message_id(trace_instance, monkeypatch): error="", ) - monkeypatch.setattr("core.ops.langfuse_trace.langfuse_trace.sessionmaker", lambda bind: lambda: MagicMock()) - monkeypatch.setattr("core.ops.langfuse_trace.langfuse_trace.db", MagicMock(engine="engine")) + monkeypatch.setattr("dify_trace_langfuse.langfuse_trace.sessionmaker", lambda bind: lambda: MagicMock()) + monkeypatch.setattr("dify_trace_langfuse.langfuse_trace.db", MagicMock(engine="engine")) repo = MagicMock() repo.get_by_workflow_execution.return_value = [] mock_factory = MagicMock() mock_factory.create_workflow_node_execution_repository.return_value = repo - monkeypatch.setattr("core.ops.langfuse_trace.langfuse_trace.DifyCoreRepositoryFactory", mock_factory) + monkeypatch.setattr("dify_trace_langfuse.langfuse_trace.DifyCoreRepositoryFactory", mock_factory) monkeypatch.setattr(trace_instance, "get_service_account_with_tenant", lambda app_id: MagicMock()) trace_instance.add_trace = MagicMock() @@ -280,8 +280,8 @@ def test_workflow_trace_missing_app_id(trace_instance, monkeypatch): workflow_app_log_id="log-1", error="", ) - monkeypatch.setattr("core.ops.langfuse_trace.langfuse_trace.sessionmaker", lambda bind: lambda: MagicMock()) - monkeypatch.setattr("core.ops.langfuse_trace.langfuse_trace.db", MagicMock(engine="engine")) + monkeypatch.setattr("dify_trace_langfuse.langfuse_trace.sessionmaker", lambda bind: lambda: MagicMock()) + monkeypatch.setattr("dify_trace_langfuse.langfuse_trace.db", MagicMock(engine="engine")) with pytest.raises(ValueError, match="No app_id found in trace_info metadata"): trace_instance.workflow_trace(trace_info) @@ -365,7 +365,7 @@ def test_message_trace_with_end_user(trace_instance, monkeypatch): mock_end_user = MagicMock(spec=EndUser) mock_end_user.session_id = "session-id-123" - monkeypatch.setattr("core.ops.langfuse_trace.langfuse_trace.db.session.get", lambda model, pk: mock_end_user) + monkeypatch.setattr("dify_trace_langfuse.langfuse_trace.db.session.get", lambda model, pk: mock_end_user) trace_instance.add_trace = MagicMock() trace_instance.add_generation = MagicMock() @@ -681,9 +681,9 @@ def test_workflow_trace_handles_usage_extraction_error(trace_instance, monkeypat repo.get_by_workflow_execution.return_value = [node] mock_factory = MagicMock() mock_factory.create_workflow_node_execution_repository.return_value = repo - monkeypatch.setattr("core.ops.langfuse_trace.langfuse_trace.DifyCoreRepositoryFactory", mock_factory) - monkeypatch.setattr("core.ops.langfuse_trace.langfuse_trace.sessionmaker", lambda bind: lambda: MagicMock()) - monkeypatch.setattr("core.ops.langfuse_trace.langfuse_trace.db", MagicMock(engine="engine")) + monkeypatch.setattr("dify_trace_langfuse.langfuse_trace.DifyCoreRepositoryFactory", mock_factory) + monkeypatch.setattr("dify_trace_langfuse.langfuse_trace.sessionmaker", lambda bind: lambda: MagicMock()) + monkeypatch.setattr("dify_trace_langfuse.langfuse_trace.db", MagicMock(engine="engine")) monkeypatch.setattr(trace_instance, "get_service_account_with_tenant", lambda app_id: MagicMock()) trace_instance.add_trace = MagicMock() diff --git a/api/providers/trace/trace-langfuse/tests/unit_tests/test_config_entity.py b/api/providers/trace/trace-langfuse/tests/unit_tests/test_config_entity.py new file mode 100644 index 0000000000..103d888eef --- /dev/null +++ b/api/providers/trace/trace-langfuse/tests/unit_tests/test_config_entity.py @@ -0,0 +1,42 @@ +import pytest +from dify_trace_langfuse.config import LangfuseConfig +from pydantic import ValidationError + + +class TestLangfuseConfig: + """Test cases for LangfuseConfig""" + + def test_valid_config(self): + """Test valid Langfuse configuration""" + config = LangfuseConfig(public_key="public_key", secret_key="secret_key", host="https://custom.langfuse.com") + assert config.public_key == "public_key" + assert config.secret_key == "secret_key" + assert config.host == "https://custom.langfuse.com" + + def test_valid_config_with_path(self): + host = "https://custom.langfuse.com/api/v1" + config = LangfuseConfig(public_key="public_key", secret_key="secret_key", host=host) + assert config.public_key == "public_key" + assert config.secret_key == "secret_key" + assert config.host == host + + def test_default_values(self): + """Test default values are set correctly""" + config = LangfuseConfig(public_key="public", secret_key="secret") + assert config.host == "https://api.langfuse.com" + + def test_missing_required_fields(self): + """Test that required fields are enforced""" + with pytest.raises(ValidationError): + LangfuseConfig() + + with pytest.raises(ValidationError): + LangfuseConfig(public_key="public") + + with pytest.raises(ValidationError): + LangfuseConfig(secret_key="secret") + + def test_host_validation_empty(self): + """Test host validation with empty value""" + config = LangfuseConfig(public_key="public", secret_key="secret", host="") + assert config.host == "https://api.langfuse.com" diff --git a/api/tests/unit_tests/core/ops/test_langfuse_trace.py b/api/providers/trace/trace-langfuse/tests/unit_tests/test_langfuse_trace.py similarity index 92% rename from api/tests/unit_tests/core/ops/test_langfuse_trace.py rename to api/providers/trace/trace-langfuse/tests/unit_tests/test_langfuse_trace.py index 017ac8c891..0340ffb669 100644 --- a/api/tests/unit_tests/core/ops/test_langfuse_trace.py +++ b/api/providers/trace/trace-langfuse/tests/unit_tests/test_langfuse_trace.py @@ -4,14 +4,15 @@ from datetime import datetime, timedelta from types import SimpleNamespace from unittest.mock import MagicMock, patch -from core.ops.entities.config_entity import LangfuseConfig +from dify_trace_langfuse.config import LangfuseConfig +from dify_trace_langfuse.langfuse_trace import LangFuseDataTrace + from core.ops.entities.trace_entity import MessageTraceInfo, WorkflowTraceInfo -from core.ops.langfuse_trace.langfuse_trace import LangFuseDataTrace from graphon.enums import BuiltinNodeTypes def _create_trace_instance() -> LangFuseDataTrace: - with patch("core.ops.langfuse_trace.langfuse_trace.Langfuse", autospec=True): + with patch("dify_trace_langfuse.langfuse_trace.Langfuse", autospec=True): return LangFuseDataTrace( LangfuseConfig( public_key="public-key", @@ -116,9 +117,9 @@ class TestLangFuseDataTraceCompletionStartTime: patch.object(trace, "add_span"), patch.object(trace, "add_generation") as add_generation, patch.object(trace, "get_service_account_with_tenant", return_value=MagicMock()), - patch("core.ops.langfuse_trace.langfuse_trace.db", MagicMock()), + patch("dify_trace_langfuse.langfuse_trace.db", MagicMock()), patch( - "core.ops.langfuse_trace.langfuse_trace.DifyCoreRepositoryFactory.create_workflow_node_execution_repository", + "dify_trace_langfuse.langfuse_trace.DifyCoreRepositoryFactory.create_workflow_node_execution_repository", return_value=repository, ), ): diff --git a/api/providers/trace/trace-langsmith/pyproject.toml b/api/providers/trace/trace-langsmith/pyproject.toml new file mode 100644 index 0000000000..8131952b28 --- /dev/null +++ b/api/providers/trace/trace-langsmith/pyproject.toml @@ -0,0 +1,10 @@ +[project] +name = "dify-trace-langsmith" +version = "0.0.1" +dependencies = [ + "langsmith~=0.7.30", +] +description = "Dify ops tracing provider (LangSmith)." + +[tool.setuptools.packages.find] +where = ["src"] diff --git a/api/core/ops/opik_trace/__init__.py b/api/providers/trace/trace-langsmith/src/dify_trace_langsmith/__init__.py similarity index 100% rename from api/core/ops/opik_trace/__init__.py rename to api/providers/trace/trace-langsmith/src/dify_trace_langsmith/__init__.py diff --git a/api/providers/trace/trace-langsmith/src/dify_trace_langsmith/config.py b/api/providers/trace/trace-langsmith/src/dify_trace_langsmith/config.py new file mode 100644 index 0000000000..498b8c5e7e --- /dev/null +++ b/api/providers/trace/trace-langsmith/src/dify_trace_langsmith/config.py @@ -0,0 +1,20 @@ +from pydantic import ValidationInfo, field_validator + +from core.ops.entities.config_entity import BaseTracingConfig +from core.ops.utils import validate_url + + +class LangSmithConfig(BaseTracingConfig): + """ + Model class for Langsmith tracing config. + """ + + api_key: str + project: str + endpoint: str = "https://api.smith.langchain.com" + + @field_validator("endpoint") + @classmethod + def endpoint_validator(cls, v, info: ValidationInfo): + # LangSmith only allows HTTPS + return validate_url(v, "https://api.smith.langchain.com", allowed_schemes=("https",)) diff --git a/api/core/ops/tencent_trace/__init__.py b/api/providers/trace/trace-langsmith/src/dify_trace_langsmith/entities/__init__.py similarity index 100% rename from api/core/ops/tencent_trace/__init__.py rename to api/providers/trace/trace-langsmith/src/dify_trace_langsmith/entities/__init__.py diff --git a/api/core/ops/langsmith_trace/entities/langsmith_trace_entity.py b/api/providers/trace/trace-langsmith/src/dify_trace_langsmith/entities/langsmith_trace_entity.py similarity index 100% rename from api/core/ops/langsmith_trace/entities/langsmith_trace_entity.py rename to api/providers/trace/trace-langsmith/src/dify_trace_langsmith/entities/langsmith_trace_entity.py diff --git a/api/core/ops/langsmith_trace/langsmith_trace.py b/api/providers/trace/trace-langsmith/src/dify_trace_langsmith/langsmith_trace.py similarity index 99% rename from api/core/ops/langsmith_trace/langsmith_trace.py rename to api/providers/trace/trace-langsmith/src/dify_trace_langsmith/langsmith_trace.py index d960038f15..145bd70dbc 100644 --- a/api/core/ops/langsmith_trace/langsmith_trace.py +++ b/api/providers/trace/trace-langsmith/src/dify_trace_langsmith/langsmith_trace.py @@ -9,7 +9,6 @@ from langsmith.schemas import RunBase from sqlalchemy.orm import sessionmaker from core.ops.base_trace_instance import BaseTraceInstance -from core.ops.entities.config_entity import LangSmithConfig from core.ops.entities.trace_entity import ( BaseTraceInfo, DatasetRetrievalTraceInfo, @@ -21,13 +20,14 @@ from core.ops.entities.trace_entity import ( TraceTaskName, WorkflowTraceInfo, ) -from core.ops.langsmith_trace.entities.langsmith_trace_entity import ( +from core.ops.utils import filter_none_values, generate_dotted_order +from core.repositories import DifyCoreRepositoryFactory +from dify_trace_langsmith.config import LangSmithConfig +from dify_trace_langsmith.entities.langsmith_trace_entity import ( LangSmithRunModel, LangSmithRunType, LangSmithRunUpdateModel, ) -from core.ops.utils import filter_none_values, generate_dotted_order -from core.repositories import DifyCoreRepositoryFactory from extensions.ext_database import db from graphon.enums import BuiltinNodeTypes, WorkflowNodeExecutionMetadataKey from models import EndUser, MessageFile, WorkflowNodeExecutionTriggeredFrom diff --git a/api/core/ops/weave_trace/__init__.py b/api/providers/trace/trace-langsmith/src/dify_trace_langsmith/py.typed similarity index 100% rename from api/core/ops/weave_trace/__init__.py rename to api/providers/trace/trace-langsmith/src/dify_trace_langsmith/py.typed diff --git a/api/tests/unit_tests/core/ops/langsmith_trace/test_langsmith_trace.py b/api/providers/trace/trace-langsmith/tests/unit_tests/langsmith_trace/test_langsmith_trace.py similarity index 91% rename from api/tests/unit_tests/core/ops/langsmith_trace/test_langsmith_trace.py rename to api/providers/trace/trace-langsmith/tests/unit_tests/langsmith_trace/test_langsmith_trace.py index 34c64c54a1..45e5894e4a 100644 --- a/api/tests/unit_tests/core/ops/langsmith_trace/test_langsmith_trace.py +++ b/api/providers/trace/trace-langsmith/tests/unit_tests/langsmith_trace/test_langsmith_trace.py @@ -3,8 +3,14 @@ from datetime import datetime, timedelta from unittest.mock import MagicMock import pytest +from dify_trace_langsmith.config import LangSmithConfig +from dify_trace_langsmith.entities.langsmith_trace_entity import ( + LangSmithRunModel, + LangSmithRunType, + LangSmithRunUpdateModel, +) +from dify_trace_langsmith.langsmith_trace import LangSmithDataTrace -from core.ops.entities.config_entity import LangSmithConfig from core.ops.entities.trace_entity import ( DatasetRetrievalTraceInfo, GenerateNameTraceInfo, @@ -15,12 +21,6 @@ from core.ops.entities.trace_entity import ( TraceTaskName, WorkflowTraceInfo, ) -from core.ops.langsmith_trace.entities.langsmith_trace_entity import ( - LangSmithRunModel, - LangSmithRunType, - LangSmithRunUpdateModel, -) -from core.ops.langsmith_trace.langsmith_trace import LangSmithDataTrace from graphon.enums import BuiltinNodeTypes, WorkflowNodeExecutionMetadataKey from models import EndUser @@ -38,7 +38,7 @@ def langsmith_config(): def trace_instance(langsmith_config, monkeypatch): # Mock LangSmith client mock_client = MagicMock() - monkeypatch.setattr("core.ops.langsmith_trace.langsmith_trace.Client", lambda **kwargs: mock_client) + monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.Client", lambda **kwargs: mock_client) instance = LangSmithDataTrace(langsmith_config) return instance @@ -46,7 +46,7 @@ def trace_instance(langsmith_config, monkeypatch): def test_init(langsmith_config, monkeypatch): mock_client_class = MagicMock() - monkeypatch.setattr("core.ops.langsmith_trace.langsmith_trace.Client", mock_client_class) + monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.Client", mock_client_class) monkeypatch.setenv("FILES_URL", "http://test.url") instance = LangSmithDataTrace(langsmith_config) @@ -138,8 +138,8 @@ def test_workflow_trace(trace_instance, monkeypatch): # Mock dependencies mock_session = MagicMock() - monkeypatch.setattr("core.ops.langsmith_trace.langsmith_trace.sessionmaker", lambda bind: lambda: mock_session) - monkeypatch.setattr("core.ops.langsmith_trace.langsmith_trace.db", MagicMock(engine="engine")) + monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.sessionmaker", lambda bind: lambda: mock_session) + monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.db", MagicMock(engine="engine")) # Mock node executions node_llm = MagicMock() @@ -188,7 +188,7 @@ def test_workflow_trace(trace_instance, monkeypatch): mock_factory = MagicMock() mock_factory.create_workflow_node_execution_repository.return_value = repo - monkeypatch.setattr("core.ops.langsmith_trace.langsmith_trace.DifyCoreRepositoryFactory", mock_factory) + monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.DifyCoreRepositoryFactory", mock_factory) monkeypatch.setattr(trace_instance, "get_service_account_with_tenant", lambda app_id: MagicMock()) @@ -252,13 +252,13 @@ def test_workflow_trace_no_start_time(trace_instance, monkeypatch): ) mock_session = MagicMock() - monkeypatch.setattr("core.ops.langsmith_trace.langsmith_trace.sessionmaker", lambda bind: lambda: mock_session) - monkeypatch.setattr("core.ops.langsmith_trace.langsmith_trace.db", MagicMock(engine="engine")) + monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.sessionmaker", lambda bind: lambda: mock_session) + monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.db", MagicMock(engine="engine")) repo = MagicMock() repo.get_by_workflow_execution.return_value = [] mock_factory = MagicMock() mock_factory.create_workflow_node_execution_repository.return_value = repo - monkeypatch.setattr("core.ops.langsmith_trace.langsmith_trace.DifyCoreRepositoryFactory", mock_factory) + monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.DifyCoreRepositoryFactory", mock_factory) monkeypatch.setattr(trace_instance, "get_service_account_with_tenant", lambda app_id: MagicMock()) trace_instance.add_run = MagicMock() @@ -283,8 +283,8 @@ def test_workflow_trace_missing_app_id(trace_instance, monkeypatch): trace_info.error = "" mock_session = MagicMock() - monkeypatch.setattr("core.ops.langsmith_trace.langsmith_trace.sessionmaker", lambda bind: lambda: mock_session) - monkeypatch.setattr("core.ops.langsmith_trace.langsmith_trace.db", MagicMock(engine="engine")) + monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.sessionmaker", lambda bind: lambda: mock_session) + monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.db", MagicMock(engine="engine")) with pytest.raises(ValueError, match="No app_id found in trace_info metadata"): trace_instance.workflow_trace(trace_info) @@ -319,7 +319,7 @@ def test_message_trace(trace_instance, monkeypatch): # Mock EndUser lookup mock_end_user = MagicMock(spec=EndUser) mock_end_user.session_id = "session-id-123" - monkeypatch.setattr("core.ops.langsmith_trace.langsmith_trace.db.session.get", lambda model, pk: mock_end_user) + monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.db.session.get", lambda model, pk: mock_end_user) trace_instance.add_run = MagicMock() @@ -567,9 +567,9 @@ def test_workflow_trace_usage_extraction_error(trace_instance, monkeypatch, capl mock_factory = MagicMock() mock_factory.create_workflow_node_execution_repository.return_value = repo - monkeypatch.setattr("core.ops.langsmith_trace.langsmith_trace.DifyCoreRepositoryFactory", mock_factory) - monkeypatch.setattr("core.ops.langsmith_trace.langsmith_trace.sessionmaker", lambda bind: lambda: MagicMock()) - monkeypatch.setattr("core.ops.langsmith_trace.langsmith_trace.db", MagicMock(engine="engine")) + monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.DifyCoreRepositoryFactory", mock_factory) + monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.sessionmaker", lambda bind: lambda: MagicMock()) + monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.db", MagicMock(engine="engine")) monkeypatch.setattr(trace_instance, "get_service_account_with_tenant", lambda app_id: MagicMock()) trace_instance.add_run = MagicMock() diff --git a/api/providers/trace/trace-langsmith/tests/unit_tests/test_config_entity.py b/api/providers/trace/trace-langsmith/tests/unit_tests/test_config_entity.py new file mode 100644 index 0000000000..37efaf69cf --- /dev/null +++ b/api/providers/trace/trace-langsmith/tests/unit_tests/test_config_entity.py @@ -0,0 +1,35 @@ +import pytest +from dify_trace_langsmith.config import LangSmithConfig +from pydantic import ValidationError + + +class TestLangSmithConfig: + """Test cases for LangSmithConfig""" + + def test_valid_config(self): + """Test valid LangSmith configuration""" + config = LangSmithConfig(api_key="test_key", project="test_project", endpoint="https://custom.smith.com") + assert config.api_key == "test_key" + assert config.project == "test_project" + assert config.endpoint == "https://custom.smith.com" + + def test_default_values(self): + """Test default values are set correctly""" + config = LangSmithConfig(api_key="key", project="project") + assert config.endpoint == "https://api.smith.langchain.com" + + def test_missing_required_fields(self): + """Test that required fields are enforced""" + with pytest.raises(ValidationError): + LangSmithConfig() + + with pytest.raises(ValidationError): + LangSmithConfig(api_key="key") + + with pytest.raises(ValidationError): + LangSmithConfig(project="project") + + def test_endpoint_validation_https_only(self): + """Test endpoint validation only allows HTTPS""" + with pytest.raises(ValidationError, match="URL scheme must be one of"): + LangSmithConfig(api_key="key", project="project", endpoint="http://insecure.com") diff --git a/api/providers/trace/trace-mlflow/pyproject.toml b/api/providers/trace/trace-mlflow/pyproject.toml new file mode 100644 index 0000000000..fad6002944 --- /dev/null +++ b/api/providers/trace/trace-mlflow/pyproject.toml @@ -0,0 +1,10 @@ +[project] +name = "dify-trace-mlflow" +version = "0.0.1" +dependencies = [ + "mlflow-skinny>=3.11.1", +] +description = "Dify ops tracing provider (MLflow / Databricks)." + +[tool.setuptools.packages.find] +where = ["src"] diff --git a/api/core/ops/weave_trace/entities/__init__.py b/api/providers/trace/trace-mlflow/src/dify_trace_mlflow/__init__.py similarity index 100% rename from api/core/ops/weave_trace/entities/__init__.py rename to api/providers/trace/trace-mlflow/src/dify_trace_mlflow/__init__.py diff --git a/api/providers/trace/trace-mlflow/src/dify_trace_mlflow/config.py b/api/providers/trace/trace-mlflow/src/dify_trace_mlflow/config.py new file mode 100644 index 0000000000..84914165e3 --- /dev/null +++ b/api/providers/trace/trace-mlflow/src/dify_trace_mlflow/config.py @@ -0,0 +1,46 @@ +from pydantic import ValidationInfo, field_validator + +from core.ops.entities.config_entity import BaseTracingConfig +from core.ops.utils import validate_integer_id, validate_url_with_path + + +class MLflowConfig(BaseTracingConfig): + """ + Model class for MLflow tracing config. + """ + + tracking_uri: str = "http://localhost:5000" + experiment_id: str = "0" # Default experiment id in MLflow is 0 + username: str | None = None + password: str | None = None + + @field_validator("tracking_uri") + @classmethod + def tracking_uri_validator(cls, v, info: ValidationInfo): + if isinstance(v, str) and v.startswith("databricks"): + raise ValueError( + "Please use Databricks tracing config below to record traces to Databricks-managed MLflow instances." + ) + return validate_url_with_path(v, "http://localhost:5000") + + @field_validator("experiment_id") + @classmethod + def experiment_id_validator(cls, v, info: ValidationInfo): + return validate_integer_id(v) + + +class DatabricksConfig(BaseTracingConfig): + """ + Model class for Databricks (Databricks-managed MLflow) tracing config. + """ + + experiment_id: str + host: str + client_id: str | None = None + client_secret: str | None = None + personal_access_token: str | None = None + + @field_validator("experiment_id") + @classmethod + def experiment_id_validator(cls, v, info: ValidationInfo): + return validate_integer_id(v) diff --git a/api/core/ops/mlflow_trace/mlflow_trace.py b/api/providers/trace/trace-mlflow/src/dify_trace_mlflow/mlflow_trace.py similarity index 99% rename from api/core/ops/mlflow_trace/mlflow_trace.py rename to api/providers/trace/trace-mlflow/src/dify_trace_mlflow/mlflow_trace.py index 87fcaeabcc..4e4c45a532 100644 --- a/api/core/ops/mlflow_trace/mlflow_trace.py +++ b/api/providers/trace/trace-mlflow/src/dify_trace_mlflow/mlflow_trace.py @@ -11,7 +11,6 @@ from mlflow.tracing.provider import detach_span_from_context, set_span_in_contex from sqlalchemy import select from core.ops.base_trace_instance import BaseTraceInstance -from core.ops.entities.config_entity import DatabricksConfig, MLflowConfig from core.ops.entities.trace_entity import ( BaseTraceInfo, DatasetRetrievalTraceInfo, @@ -24,6 +23,7 @@ from core.ops.entities.trace_entity import ( WorkflowTraceInfo, ) from core.ops.utils import JSON_DICT_ADAPTER +from dify_trace_mlflow.config import DatabricksConfig, MLflowConfig from extensions.ext_database import db from graphon.enums import BuiltinNodeTypes from models import EndUser diff --git a/api/providers/trace/trace-mlflow/src/dify_trace_mlflow/py.typed b/api/providers/trace/trace-mlflow/src/dify_trace_mlflow/py.typed new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api/tests/unit_tests/core/ops/mlflow_trace/test_mlflow_trace.py b/api/providers/trace/trace-mlflow/tests/unit_tests/mlflow_trace/test_mlflow_trace.py similarity index 98% rename from api/tests/unit_tests/core/ops/mlflow_trace/test_mlflow_trace.py rename to api/providers/trace/trace-mlflow/tests/unit_tests/mlflow_trace/test_mlflow_trace.py index afc5726ede..20211456e3 100644 --- a/api/tests/unit_tests/core/ops/mlflow_trace/test_mlflow_trace.py +++ b/api/providers/trace/trace-mlflow/tests/unit_tests/mlflow_trace/test_mlflow_trace.py @@ -1,4 +1,4 @@ -"""Comprehensive tests for core.ops.mlflow_trace.mlflow_trace module.""" +"""Comprehensive tests for dify_trace_mlflow.mlflow_trace module.""" from __future__ import annotations @@ -9,8 +9,9 @@ from types import SimpleNamespace from unittest.mock import MagicMock, patch import pytest +from dify_trace_mlflow.config import DatabricksConfig, MLflowConfig +from dify_trace_mlflow.mlflow_trace import MLflowDataTrace, datetime_to_nanoseconds -from core.ops.entities.config_entity import DatabricksConfig, MLflowConfig from core.ops.entities.trace_entity import ( DatasetRetrievalTraceInfo, GenerateNameTraceInfo, @@ -20,7 +21,6 @@ from core.ops.entities.trace_entity import ( ToolTraceInfo, WorkflowTraceInfo, ) -from core.ops.mlflow_trace.mlflow_trace import MLflowDataTrace, datetime_to_nanoseconds from graphon.enums import BuiltinNodeTypes # ── Helpers ────────────────────────────────────────────────────────────────── @@ -179,7 +179,7 @@ def _make_node(**overrides): @pytest.fixture def mock_mlflow(): - with patch("core.ops.mlflow_trace.mlflow_trace.mlflow") as mock: + with patch("dify_trace_mlflow.mlflow_trace.mlflow") as mock: yield mock @@ -187,10 +187,10 @@ def mock_mlflow(): def mock_tracing(): """Patch all MLflow tracing functions used by the module.""" with ( - patch("core.ops.mlflow_trace.mlflow_trace.start_span_no_context") as mock_start, - patch("core.ops.mlflow_trace.mlflow_trace.update_current_trace") as mock_update, - patch("core.ops.mlflow_trace.mlflow_trace.set_span_in_context") as mock_set, - patch("core.ops.mlflow_trace.mlflow_trace.detach_span_from_context") as mock_detach, + patch("dify_trace_mlflow.mlflow_trace.start_span_no_context") as mock_start, + patch("dify_trace_mlflow.mlflow_trace.update_current_trace") as mock_update, + patch("dify_trace_mlflow.mlflow_trace.set_span_in_context") as mock_set, + patch("dify_trace_mlflow.mlflow_trace.detach_span_from_context") as mock_detach, ): yield { "start": mock_start, @@ -202,7 +202,7 @@ def mock_tracing(): @pytest.fixture def mock_db(): - with patch("core.ops.mlflow_trace.mlflow_trace.db") as mock: + with patch("dify_trace_mlflow.mlflow_trace.db") as mock: yield mock diff --git a/api/providers/trace/trace-opik/pyproject.toml b/api/providers/trace/trace-opik/pyproject.toml new file mode 100644 index 0000000000..874997168e --- /dev/null +++ b/api/providers/trace/trace-opik/pyproject.toml @@ -0,0 +1,10 @@ +[project] +name = "dify-trace-opik" +version = "0.0.1" +dependencies = [ + "opik~=1.11.2", +] +description = "Dify ops tracing provider (Opik)." + +[tool.setuptools.packages.find] +where = ["src"] diff --git a/api/providers/trace/trace-opik/src/dify_trace_opik/__init__.py b/api/providers/trace/trace-opik/src/dify_trace_opik/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api/providers/trace/trace-opik/src/dify_trace_opik/config.py b/api/providers/trace/trace-opik/src/dify_trace_opik/config.py new file mode 100644 index 0000000000..c16ff1d903 --- /dev/null +++ b/api/providers/trace/trace-opik/src/dify_trace_opik/config.py @@ -0,0 +1,25 @@ +from pydantic import ValidationInfo, field_validator + +from core.ops.entities.config_entity import BaseTracingConfig +from core.ops.utils import validate_url_with_path + + +class OpikConfig(BaseTracingConfig): + """ + Model class for Opik tracing config. + """ + + api_key: str | None = None + project: str | None = None + workspace: str | None = None + url: str = "https://www.comet.com/opik/api/" + + @field_validator("project") + @classmethod + def project_validator(cls, v, info: ValidationInfo): + return cls.validate_project_field(v, "Default Project") + + @field_validator("url") + @classmethod + def url_validator(cls, v, info: ValidationInfo): + return validate_url_with_path(v, "https://www.comet.com/opik/api/", required_suffix="/api/") diff --git a/api/core/ops/opik_trace/opik_trace.py b/api/providers/trace/trace-opik/src/dify_trace_opik/opik_trace.py similarity index 99% rename from api/core/ops/opik_trace/opik_trace.py rename to api/providers/trace/trace-opik/src/dify_trace_opik/opik_trace.py index 672efe45bd..2d124ac989 100644 --- a/api/core/ops/opik_trace/opik_trace.py +++ b/api/providers/trace/trace-opik/src/dify_trace_opik/opik_trace.py @@ -10,7 +10,6 @@ from opik.id_helpers import uuid4_to_uuid7 from sqlalchemy.orm import sessionmaker from core.ops.base_trace_instance import BaseTraceInstance -from core.ops.entities.config_entity import OpikConfig from core.ops.entities.trace_entity import ( BaseTraceInfo, DatasetRetrievalTraceInfo, @@ -23,6 +22,7 @@ from core.ops.entities.trace_entity import ( WorkflowTraceInfo, ) from core.repositories import DifyCoreRepositoryFactory +from dify_trace_opik.config import OpikConfig from extensions.ext_database import db from graphon.enums import BuiltinNodeTypes, WorkflowNodeExecutionMetadataKey from models import EndUser, MessageFile, WorkflowNodeExecutionTriggeredFrom diff --git a/api/providers/trace/trace-opik/src/dify_trace_opik/py.typed b/api/providers/trace/trace-opik/src/dify_trace_opik/py.typed new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api/tests/unit_tests/core/ops/opik_trace/test_opik_trace.py b/api/providers/trace/trace-opik/tests/unit_tests/opik_trace/test_opik_trace.py similarity index 93% rename from api/tests/unit_tests/core/ops/opik_trace/test_opik_trace.py rename to api/providers/trace/trace-opik/tests/unit_tests/opik_trace/test_opik_trace.py index c02ac413f2..eefed3c78c 100644 --- a/api/tests/unit_tests/core/ops/opik_trace/test_opik_trace.py +++ b/api/providers/trace/trace-opik/tests/unit_tests/opik_trace/test_opik_trace.py @@ -5,8 +5,9 @@ from types import SimpleNamespace from unittest.mock import MagicMock import pytest +from dify_trace_opik.config import OpikConfig +from dify_trace_opik.opik_trace import OpikDataTrace, prepare_opik_uuid, wrap_dict, wrap_metadata -from core.ops.entities.config_entity import OpikConfig from core.ops.entities.trace_entity import ( DatasetRetrievalTraceInfo, GenerateNameTraceInfo, @@ -17,7 +18,6 @@ from core.ops.entities.trace_entity import ( TraceTaskName, WorkflowTraceInfo, ) -from core.ops.opik_trace.opik_trace import OpikDataTrace, prepare_opik_uuid, wrap_dict, wrap_metadata from graphon.enums import BuiltinNodeTypes, WorkflowNodeExecutionMetadataKey from models import EndUser from models.enums import MessageStatus @@ -37,7 +37,7 @@ def opik_config(): @pytest.fixture def trace_instance(opik_config, monkeypatch): mock_client = MagicMock() - monkeypatch.setattr("core.ops.opik_trace.opik_trace.Opik", lambda **kwargs: mock_client) + monkeypatch.setattr("dify_trace_opik.opik_trace.Opik", lambda **kwargs: mock_client) instance = OpikDataTrace(opik_config) return instance @@ -67,7 +67,7 @@ def test_prepare_opik_uuid(): def test_init(opik_config, monkeypatch): mock_opik = MagicMock() - monkeypatch.setattr("core.ops.opik_trace.opik_trace.Opik", mock_opik) + monkeypatch.setattr("dify_trace_opik.opik_trace.Opik", mock_opik) monkeypatch.setenv("FILES_URL", "http://test.url") instance = OpikDataTrace(opik_config) @@ -166,8 +166,8 @@ def test_workflow_trace_with_message_id(trace_instance, monkeypatch): ) mock_session = MagicMock() - monkeypatch.setattr("core.ops.opik_trace.opik_trace.sessionmaker", lambda bind: lambda: mock_session) - monkeypatch.setattr("core.ops.opik_trace.opik_trace.db", MagicMock(engine="engine")) + monkeypatch.setattr("dify_trace_opik.opik_trace.sessionmaker", lambda bind: lambda: mock_session) + monkeypatch.setattr("dify_trace_opik.opik_trace.db", MagicMock(engine="engine")) node_llm = MagicMock() node_llm.id = LLM_NODE_ID @@ -203,7 +203,7 @@ def test_workflow_trace_with_message_id(trace_instance, monkeypatch): mock_factory = MagicMock() mock_factory.create_workflow_node_execution_repository.return_value = repo - monkeypatch.setattr("core.ops.opik_trace.opik_trace.DifyCoreRepositoryFactory", mock_factory) + monkeypatch.setattr("dify_trace_opik.opik_trace.DifyCoreRepositoryFactory", mock_factory) monkeypatch.setattr(trace_instance, "get_service_account_with_tenant", lambda app_id: MagicMock()) @@ -250,13 +250,13 @@ def test_workflow_trace_no_message_id(trace_instance, monkeypatch): error="", ) - monkeypatch.setattr("core.ops.opik_trace.opik_trace.sessionmaker", lambda bind: lambda: MagicMock()) - monkeypatch.setattr("core.ops.opik_trace.opik_trace.db", MagicMock(engine="engine")) + monkeypatch.setattr("dify_trace_opik.opik_trace.sessionmaker", lambda bind: lambda: MagicMock()) + monkeypatch.setattr("dify_trace_opik.opik_trace.db", MagicMock(engine="engine")) repo = MagicMock() repo.get_by_workflow_execution.return_value = [] mock_factory = MagicMock() mock_factory.create_workflow_node_execution_repository.return_value = repo - monkeypatch.setattr("core.ops.opik_trace.opik_trace.DifyCoreRepositoryFactory", mock_factory) + monkeypatch.setattr("dify_trace_opik.opik_trace.DifyCoreRepositoryFactory", mock_factory) monkeypatch.setattr(trace_instance, "get_service_account_with_tenant", lambda app_id: MagicMock()) trace_instance.add_trace = MagicMock() @@ -286,8 +286,8 @@ def test_workflow_trace_missing_app_id(trace_instance, monkeypatch): workflow_app_log_id="339760b2-4b94-4532-8c81-133a97e4680e", error="", ) - monkeypatch.setattr("core.ops.opik_trace.opik_trace.sessionmaker", lambda bind: lambda: MagicMock()) - monkeypatch.setattr("core.ops.opik_trace.opik_trace.db", MagicMock(engine="engine")) + monkeypatch.setattr("dify_trace_opik.opik_trace.sessionmaker", lambda bind: lambda: MagicMock()) + monkeypatch.setattr("dify_trace_opik.opik_trace.db", MagicMock(engine="engine")) with pytest.raises(ValueError, match="No app_id found in trace_info metadata"): trace_instance.workflow_trace(trace_info) @@ -373,7 +373,7 @@ def test_message_trace_with_end_user(trace_instance, monkeypatch): mock_end_user = MagicMock(spec=EndUser) mock_end_user.session_id = "session-id-123" - monkeypatch.setattr("core.ops.opik_trace.opik_trace.db.session.get", lambda model, pk: mock_end_user) + monkeypatch.setattr("dify_trace_opik.opik_trace.db.session.get", lambda model, pk: mock_end_user) trace_instance.add_trace = MagicMock(return_value=MagicMock(id="trace_id_2")) trace_instance.add_span = MagicMock() @@ -658,9 +658,9 @@ def test_workflow_trace_usage_extraction_error_fixed(trace_instance, monkeypatch repo.get_by_workflow_execution.return_value = [node] mock_factory = MagicMock() mock_factory.create_workflow_node_execution_repository.return_value = repo - monkeypatch.setattr("core.ops.opik_trace.opik_trace.DifyCoreRepositoryFactory", mock_factory) - monkeypatch.setattr("core.ops.opik_trace.opik_trace.sessionmaker", lambda bind: lambda: MagicMock()) - monkeypatch.setattr("core.ops.opik_trace.opik_trace.db", MagicMock(engine="engine")) + monkeypatch.setattr("dify_trace_opik.opik_trace.DifyCoreRepositoryFactory", mock_factory) + monkeypatch.setattr("dify_trace_opik.opik_trace.sessionmaker", lambda bind: lambda: MagicMock()) + monkeypatch.setattr("dify_trace_opik.opik_trace.db", MagicMock(engine="engine")) monkeypatch.setattr(trace_instance, "get_service_account_with_tenant", lambda app_id: MagicMock()) trace_instance.add_trace = MagicMock() diff --git a/api/providers/trace/trace-opik/tests/unit_tests/test_config_entity.py b/api/providers/trace/trace-opik/tests/unit_tests/test_config_entity.py new file mode 100644 index 0000000000..5a54b70bba --- /dev/null +++ b/api/providers/trace/trace-opik/tests/unit_tests/test_config_entity.py @@ -0,0 +1,48 @@ +import pytest +from dify_trace_opik.config import OpikConfig +from pydantic import ValidationError + + +class TestOpikConfig: + """Test cases for OpikConfig""" + + def test_valid_config(self): + """Test valid Opik configuration""" + config = OpikConfig( + api_key="test_key", + project="test_project", + workspace="test_workspace", + url="https://custom.comet.com/opik/api/", + ) + assert config.api_key == "test_key" + assert config.project == "test_project" + assert config.workspace == "test_workspace" + assert config.url == "https://custom.comet.com/opik/api/" + + def test_default_values(self): + """Test default values are set correctly""" + config = OpikConfig() + assert config.api_key is None + assert config.project is None + assert config.workspace is None + assert config.url == "https://www.comet.com/opik/api/" + + def test_project_validation_empty(self): + """Test project validation with empty value""" + config = OpikConfig(project="") + assert config.project == "Default Project" + + def test_url_validation_empty(self): + """Test URL validation with empty value""" + config = OpikConfig(url="") + assert config.url == "https://www.comet.com/opik/api/" + + def test_url_validation_missing_suffix(self): + """Test URL validation requires /api/ suffix""" + with pytest.raises(ValidationError, match="URL should end with /api/"): + OpikConfig(url="https://custom.comet.com/opik/") + + def test_url_validation_invalid_scheme(self): + """Test URL validation rejects invalid schemes""" + with pytest.raises(ValidationError, match="URL must start with https:// or http://"): + OpikConfig(url="ftp://custom.comet.com/opik/api/") diff --git a/api/tests/unit_tests/core/ops/test_opik_trace.py b/api/providers/trace/trace-opik/tests/unit_tests/test_opik_trace.py similarity index 94% rename from api/tests/unit_tests/core/ops/test_opik_trace.py rename to api/providers/trace/trace-opik/tests/unit_tests/test_opik_trace.py index ad9d0846be..fba290f5b8 100644 --- a/api/tests/unit_tests/core/ops/test_opik_trace.py +++ b/api/providers/trace/trace-opik/tests/unit_tests/test_opik_trace.py @@ -14,8 +14,9 @@ import uuid from datetime import datetime from unittest.mock import MagicMock, patch +from dify_trace_opik.opik_trace import OpikDataTrace, _seed_to_uuid4, prepare_opik_uuid + from core.ops.entities.trace_entity import TraceTaskName, WorkflowTraceInfo -from core.ops.opik_trace.opik_trace import OpikDataTrace, _seed_to_uuid4, prepare_opik_uuid # A stable UUID4 used as the workflow_run_id throughout all tests. _WORKFLOW_RUN_ID = "a3f1b2c4-d5e6-4f78-9a0b-c1d2e3f4a5b6" @@ -56,8 +57,8 @@ def _make_workflow_trace_info( def _make_opik_trace_instance() -> OpikDataTrace: """Construct an OpikDataTrace with the Opik SDK client mocked out.""" - with patch("core.ops.opik_trace.opik_trace.Opik"): - from core.ops.entities.config_entity import OpikConfig + with patch("dify_trace_opik.opik_trace.Opik"): + from dify_trace_opik.config import OpikConfig config = OpikConfig(api_key="key", project="test-project", url="https://www.comet.com/opik/api/") instance = OpikDataTrace(config) @@ -133,10 +134,10 @@ class TestWorkflowTraceWithoutMessageId: fake_repo.get_by_workflow_execution.return_value = node_executions or [] with ( - patch("core.ops.opik_trace.opik_trace.db") as mock_db, - patch("core.ops.opik_trace.opik_trace.sessionmaker"), + patch("dify_trace_opik.opik_trace.db") as mock_db, + patch("dify_trace_opik.opik_trace.sessionmaker"), patch( - "core.ops.opik_trace.opik_trace.DifyCoreRepositoryFactory.create_workflow_node_execution_repository", + "dify_trace_opik.opik_trace.DifyCoreRepositoryFactory.create_workflow_node_execution_repository", return_value=fake_repo, ), ): @@ -265,10 +266,10 @@ class TestWorkflowTraceWithMessageId: fake_repo.get_by_workflow_execution.return_value = node_executions or [] with ( - patch("core.ops.opik_trace.opik_trace.db") as mock_db, - patch("core.ops.opik_trace.opik_trace.sessionmaker"), + patch("dify_trace_opik.opik_trace.db") as mock_db, + patch("dify_trace_opik.opik_trace.sessionmaker"), patch( - "core.ops.opik_trace.opik_trace.DifyCoreRepositoryFactory.create_workflow_node_execution_repository", + "dify_trace_opik.opik_trace.DifyCoreRepositoryFactory.create_workflow_node_execution_repository", return_value=fake_repo, ), ): diff --git a/api/providers/trace/trace-tencent/pyproject.toml b/api/providers/trace/trace-tencent/pyproject.toml new file mode 100644 index 0000000000..eab06fc708 --- /dev/null +++ b/api/providers/trace/trace-tencent/pyproject.toml @@ -0,0 +1,14 @@ +[project] +name = "dify-trace-tencent" +version = "0.0.1" +dependencies = [ + # versions inherited from parent + "opentelemetry-api", + "opentelemetry-exporter-otlp-proto-grpc", + "opentelemetry-sdk", + "opentelemetry-semantic-conventions", +] +description = "Dify ops tracing provider (Tencent APM)." + +[tool.setuptools.packages.find] +where = ["src"] diff --git a/api/providers/trace/trace-tencent/src/dify_trace_tencent/__init__.py b/api/providers/trace/trace-tencent/src/dify_trace_tencent/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api/core/ops/tencent_trace/client.py b/api/providers/trace/trace-tencent/src/dify_trace_tencent/client.py similarity index 100% rename from api/core/ops/tencent_trace/client.py rename to api/providers/trace/trace-tencent/src/dify_trace_tencent/client.py diff --git a/api/providers/trace/trace-tencent/src/dify_trace_tencent/config.py b/api/providers/trace/trace-tencent/src/dify_trace_tencent/config.py new file mode 100644 index 0000000000..398e6c55a8 --- /dev/null +++ b/api/providers/trace/trace-tencent/src/dify_trace_tencent/config.py @@ -0,0 +1,30 @@ +from pydantic import ValidationInfo, field_validator + +from core.ops.entities.config_entity import BaseTracingConfig + + +class TencentConfig(BaseTracingConfig): + """ + Tencent APM tracing config + """ + + token: str + endpoint: str + service_name: str + + @field_validator("token") + @classmethod + def token_validator(cls, v, info: ValidationInfo): + if not v or v.strip() == "": + raise ValueError("Token cannot be empty") + return v + + @field_validator("endpoint") + @classmethod + def endpoint_validator(cls, v, info: ValidationInfo): + return cls.validate_endpoint_url(v, "https://apm.tencentcloudapi.com") + + @field_validator("service_name") + @classmethod + def service_name_validator(cls, v, info: ValidationInfo): + return cls.validate_project_field(v, "dify_app") diff --git a/api/core/ops/tencent_trace/entities/__init__.py b/api/providers/trace/trace-tencent/src/dify_trace_tencent/entities/__init__.py similarity index 100% rename from api/core/ops/tencent_trace/entities/__init__.py rename to api/providers/trace/trace-tencent/src/dify_trace_tencent/entities/__init__.py diff --git a/api/core/ops/tencent_trace/entities/semconv.py b/api/providers/trace/trace-tencent/src/dify_trace_tencent/entities/semconv.py similarity index 100% rename from api/core/ops/tencent_trace/entities/semconv.py rename to api/providers/trace/trace-tencent/src/dify_trace_tencent/entities/semconv.py diff --git a/api/core/ops/tencent_trace/entities/tencent_trace_entity.py b/api/providers/trace/trace-tencent/src/dify_trace_tencent/entities/tencent_trace_entity.py similarity index 100% rename from api/core/ops/tencent_trace/entities/tencent_trace_entity.py rename to api/providers/trace/trace-tencent/src/dify_trace_tencent/entities/tencent_trace_entity.py diff --git a/api/providers/trace/trace-tencent/src/dify_trace_tencent/py.typed b/api/providers/trace/trace-tencent/src/dify_trace_tencent/py.typed new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api/core/ops/tencent_trace/span_builder.py b/api/providers/trace/trace-tencent/src/dify_trace_tencent/span_builder.py similarity index 98% rename from api/core/ops/tencent_trace/span_builder.py rename to api/providers/trace/trace-tencent/src/dify_trace_tencent/span_builder.py index 36878dc58f..763a85ffd7 100644 --- a/api/core/ops/tencent_trace/span_builder.py +++ b/api/providers/trace/trace-tencent/src/dify_trace_tencent/span_builder.py @@ -14,7 +14,8 @@ from core.ops.entities.trace_entity import ( ToolTraceInfo, WorkflowTraceInfo, ) -from core.ops.tencent_trace.entities.semconv import ( +from core.rag.models.document import Document +from dify_trace_tencent.entities.semconv import ( GEN_AI_COMPLETION, GEN_AI_FRAMEWORK, GEN_AI_IS_ENTRY, @@ -38,9 +39,8 @@ from core.ops.tencent_trace.entities.semconv import ( TOOL_PARAMETERS, GenAISpanKind, ) -from core.ops.tencent_trace.entities.tencent_trace_entity import SpanData -from core.ops.tencent_trace.utils import TencentTraceUtils -from core.rag.models.document import Document +from dify_trace_tencent.entities.tencent_trace_entity import SpanData +from dify_trace_tencent.utils import TencentTraceUtils from graphon.entities import WorkflowNodeExecution from graphon.enums import WorkflowNodeExecutionMetadataKey, WorkflowNodeExecutionStatus diff --git a/api/core/ops/tencent_trace/tencent_trace.py b/api/providers/trace/trace-tencent/src/dify_trace_tencent/tencent_trace.py similarity index 98% rename from api/core/ops/tencent_trace/tencent_trace.py rename to api/providers/trace/trace-tencent/src/dify_trace_tencent/tencent_trace.py index d681b9da80..cfcf6b307e 100644 --- a/api/core/ops/tencent_trace/tencent_trace.py +++ b/api/providers/trace/trace-tencent/src/dify_trace_tencent/tencent_trace.py @@ -8,7 +8,6 @@ from sqlalchemy import select from sqlalchemy.orm import Session, sessionmaker from core.ops.base_trace_instance import BaseTraceInstance -from core.ops.entities.config_entity import TencentConfig from core.ops.entities.trace_entity import ( BaseTraceInfo, DatasetRetrievalTraceInfo, @@ -19,11 +18,12 @@ from core.ops.entities.trace_entity import ( ToolTraceInfo, WorkflowTraceInfo, ) -from core.ops.tencent_trace.client import TencentTraceClient -from core.ops.tencent_trace.entities.tencent_trace_entity import SpanData -from core.ops.tencent_trace.span_builder import TencentSpanBuilder -from core.ops.tencent_trace.utils import TencentTraceUtils from core.repositories import SQLAlchemyWorkflowNodeExecutionRepository +from dify_trace_tencent.client import TencentTraceClient +from dify_trace_tencent.config import TencentConfig +from dify_trace_tencent.entities.tencent_trace_entity import SpanData +from dify_trace_tencent.span_builder import TencentSpanBuilder +from dify_trace_tencent.utils import TencentTraceUtils from extensions.ext_database import db from graphon.entities.workflow_node_execution import ( WorkflowNodeExecution, diff --git a/api/core/ops/tencent_trace/utils.py b/api/providers/trace/trace-tencent/src/dify_trace_tencent/utils.py similarity index 100% rename from api/core/ops/tencent_trace/utils.py rename to api/providers/trace/trace-tencent/src/dify_trace_tencent/utils.py diff --git a/api/tests/unit_tests/core/ops/tencent_trace/test_client.py b/api/providers/trace/trace-tencent/tests/unit_tests/tencent_trace/test_client.py similarity index 98% rename from api/tests/unit_tests/core/ops/tencent_trace/test_client.py rename to api/providers/trace/trace-tencent/tests/unit_tests/tencent_trace/test_client.py index 870c18e53e..1e656e2462 100644 --- a/api/tests/unit_tests/core/ops/tencent_trace/test_client.py +++ b/api/providers/trace/trace-tencent/tests/unit_tests/tencent_trace/test_client.py @@ -8,13 +8,12 @@ from types import SimpleNamespace from unittest.mock import MagicMock import pytest +from dify_trace_tencent import client as client_module +from dify_trace_tencent.client import TencentTraceClient, _get_opentelemetry_sdk_version +from dify_trace_tencent.entities.tencent_trace_entity import SpanData from opentelemetry.sdk.trace import Event from opentelemetry.trace import Status, StatusCode -from core.ops.tencent_trace import client as client_module -from core.ops.tencent_trace.client import TencentTraceClient, _get_opentelemetry_sdk_version -from core.ops.tencent_trace.entities.tencent_trace_entity import SpanData - metric_reader_instances: list[DummyMetricReader] = [] meter_provider_instances: list[DummyMeterProvider] = [] diff --git a/api/tests/unit_tests/core/ops/tencent_trace/test_span_builder.py b/api/providers/trace/trace-tencent/tests/unit_tests/tencent_trace/test_span_builder.py similarity index 89% rename from api/tests/unit_tests/core/ops/tencent_trace/test_span_builder.py rename to api/providers/trace/trace-tencent/tests/unit_tests/tencent_trace/test_span_builder.py index 6113e5c6c8..e850a801f3 100644 --- a/api/tests/unit_tests/core/ops/tencent_trace/test_span_builder.py +++ b/api/providers/trace/trace-tencent/tests/unit_tests/tencent_trace/test_span_builder.py @@ -1,15 +1,7 @@ from datetime import datetime from unittest.mock import MagicMock, patch -from opentelemetry.trace import StatusCode - -from core.ops.entities.trace_entity import ( - DatasetRetrievalTraceInfo, - MessageTraceInfo, - ToolTraceInfo, - WorkflowTraceInfo, -) -from core.ops.tencent_trace.entities.semconv import ( +from dify_trace_tencent.entities.semconv import ( GEN_AI_IS_ENTRY, GEN_AI_IS_STREAMING_REQUEST, GEN_AI_MODEL_NAME, @@ -23,7 +15,15 @@ from core.ops.tencent_trace.entities.semconv import ( TOOL_PARAMETERS, GenAISpanKind, ) -from core.ops.tencent_trace.span_builder import TencentSpanBuilder +from dify_trace_tencent.span_builder import TencentSpanBuilder +from opentelemetry.trace import StatusCode + +from core.ops.entities.trace_entity import ( + DatasetRetrievalTraceInfo, + MessageTraceInfo, + ToolTraceInfo, + WorkflowTraceInfo, +) from core.rag.models.document import Document from graphon.entities import WorkflowNodeExecution from graphon.enums import WorkflowNodeExecutionMetadataKey, WorkflowNodeExecutionStatus @@ -31,7 +31,7 @@ from graphon.enums import WorkflowNodeExecutionMetadataKey, WorkflowNodeExecutio class TestTencentSpanBuilder: def test_get_time_nanoseconds(self): - with patch("core.ops.tencent_trace.utils.TencentTraceUtils.convert_datetime_to_nanoseconds") as mock_convert: + with patch("dify_trace_tencent.utils.TencentTraceUtils.convert_datetime_to_nanoseconds") as mock_convert: mock_convert.return_value = 123456789 dt = datetime.now() result = TencentSpanBuilder._get_time_nanoseconds(dt) @@ -48,7 +48,7 @@ class TestTencentSpanBuilder: trace_info.workflow_run_outputs = {"answer": "world"} trace_info.metadata = {"conversation_id": "conv_id"} - with patch("core.ops.tencent_trace.utils.TencentTraceUtils.convert_to_span_id") as mock_convert_id: + with patch("dify_trace_tencent.utils.TencentTraceUtils.convert_to_span_id") as mock_convert_id: mock_convert_id.side_effect = [1, 2] # workflow_span_id, message_span_id with patch.object(TencentSpanBuilder, "_get_time_nanoseconds", return_value=100): spans = TencentSpanBuilder.build_workflow_spans(trace_info, 123, "user_1") @@ -70,7 +70,7 @@ class TestTencentSpanBuilder: trace_info.workflow_run_outputs = {} trace_info.metadata = {} # No conversation_id - with patch("core.ops.tencent_trace.utils.TencentTraceUtils.convert_to_span_id") as mock_convert_id: + with patch("dify_trace_tencent.utils.TencentTraceUtils.convert_to_span_id") as mock_convert_id: mock_convert_id.return_value = 1 with patch.object(TencentSpanBuilder, "_get_time_nanoseconds", return_value=100): spans = TencentSpanBuilder.build_workflow_spans(trace_info, 123, "user_1") @@ -98,7 +98,7 @@ class TestTencentSpanBuilder: } node_execution.outputs = {"text": "world"} - with patch("core.ops.tencent_trace.utils.TencentTraceUtils.convert_to_span_id") as mock_convert_id: + with patch("dify_trace_tencent.utils.TencentTraceUtils.convert_to_span_id") as mock_convert_id: mock_convert_id.return_value = 456 with patch.object(TencentSpanBuilder, "_get_time_nanoseconds", return_value=100): span = TencentSpanBuilder.build_workflow_llm_span(123, 1, trace_info, node_execution) @@ -123,7 +123,7 @@ class TestTencentSpanBuilder: "usage": {"prompt_tokens": 15, "completion_tokens": 25, "total_tokens": 40}, } - with patch("core.ops.tencent_trace.utils.TencentTraceUtils.convert_to_span_id") as mock_convert_id: + with patch("dify_trace_tencent.utils.TencentTraceUtils.convert_to_span_id") as mock_convert_id: mock_convert_id.return_value = 456 with patch.object(TencentSpanBuilder, "_get_time_nanoseconds", return_value=100): span = TencentSpanBuilder.build_workflow_llm_span(123, 1, trace_info, node_execution) @@ -142,7 +142,7 @@ class TestTencentSpanBuilder: trace_info.metadata = {"conversation_id": "conv_id"} trace_info.is_streaming_request = True - with patch("core.ops.tencent_trace.utils.TencentTraceUtils.convert_to_span_id") as mock_convert_id: + with patch("dify_trace_tencent.utils.TencentTraceUtils.convert_to_span_id") as mock_convert_id: mock_convert_id.return_value = 789 with patch.object(TencentSpanBuilder, "_get_time_nanoseconds", return_value=100): span = TencentSpanBuilder.build_message_span(trace_info, 123, "user_1") @@ -162,7 +162,7 @@ class TestTencentSpanBuilder: trace_info.metadata = {} trace_info.is_streaming_request = False - with patch("core.ops.tencent_trace.utils.TencentTraceUtils.convert_to_span_id") as mock_convert_id: + with patch("dify_trace_tencent.utils.TencentTraceUtils.convert_to_span_id") as mock_convert_id: mock_convert_id.return_value = 789 with patch.object(TencentSpanBuilder, "_get_time_nanoseconds", return_value=100): span = TencentSpanBuilder.build_message_span(trace_info, 123, "user_1") @@ -182,7 +182,7 @@ class TestTencentSpanBuilder: trace_info.tool_inputs = {"i": 2} trace_info.tool_outputs = "result" - with patch("core.ops.tencent_trace.utils.TencentTraceUtils.convert_to_span_id") as mock_convert_id: + with patch("dify_trace_tencent.utils.TencentTraceUtils.convert_to_span_id") as mock_convert_id: mock_convert_id.return_value = 101 with patch.object(TencentSpanBuilder, "_get_time_nanoseconds", return_value=100): span = TencentSpanBuilder.build_tool_span(trace_info, 123, 1) @@ -204,7 +204,7 @@ class TestTencentSpanBuilder: ) trace_info.documents = [doc] - with patch("core.ops.tencent_trace.utils.TencentTraceUtils.convert_to_span_id") as mock_convert_id: + with patch("dify_trace_tencent.utils.TencentTraceUtils.convert_to_span_id") as mock_convert_id: mock_convert_id.return_value = 202 with patch.object(TencentSpanBuilder, "_get_time_nanoseconds", return_value=100): span = TencentSpanBuilder.build_retrieval_span(trace_info, 123, 1) @@ -222,7 +222,7 @@ class TestTencentSpanBuilder: trace_info.end_time = datetime.now() trace_info.documents = [] - with patch("core.ops.tencent_trace.utils.TencentTraceUtils.convert_to_span_id") as mock_convert_id: + with patch("dify_trace_tencent.utils.TencentTraceUtils.convert_to_span_id") as mock_convert_id: mock_convert_id.return_value = 202 with patch.object(TencentSpanBuilder, "_get_time_nanoseconds", return_value=100): span = TencentSpanBuilder.build_retrieval_span(trace_info, 123, 1) @@ -264,7 +264,7 @@ class TestTencentSpanBuilder: node_execution.created_at = datetime.now() node_execution.finished_at = datetime.now() - with patch("core.ops.tencent_trace.utils.TencentTraceUtils.convert_to_span_id") as mock_convert_id: + with patch("dify_trace_tencent.utils.TencentTraceUtils.convert_to_span_id") as mock_convert_id: mock_convert_id.return_value = 303 with patch.object(TencentSpanBuilder, "_get_time_nanoseconds", return_value=100): span = TencentSpanBuilder.build_workflow_retrieval_span(123, 1, trace_info, node_execution) @@ -286,7 +286,7 @@ class TestTencentSpanBuilder: node_execution.created_at = datetime.now() node_execution.finished_at = datetime.now() - with patch("core.ops.tencent_trace.utils.TencentTraceUtils.convert_to_span_id") as mock_convert_id: + with patch("dify_trace_tencent.utils.TencentTraceUtils.convert_to_span_id") as mock_convert_id: mock_convert_id.return_value = 303 with patch.object(TencentSpanBuilder, "_get_time_nanoseconds", return_value=100): span = TencentSpanBuilder.build_workflow_retrieval_span(123, 1, trace_info, node_execution) @@ -307,7 +307,7 @@ class TestTencentSpanBuilder: node_execution.created_at = datetime.now() node_execution.finished_at = datetime.now() - with patch("core.ops.tencent_trace.utils.TencentTraceUtils.convert_to_span_id") as mock_convert_id: + with patch("dify_trace_tencent.utils.TencentTraceUtils.convert_to_span_id") as mock_convert_id: mock_convert_id.return_value = 404 with patch.object(TencentSpanBuilder, "_get_time_nanoseconds", return_value=100): span = TencentSpanBuilder.build_workflow_tool_span(123, 1, trace_info, node_execution) @@ -329,7 +329,7 @@ class TestTencentSpanBuilder: node_execution.created_at = datetime.now() node_execution.finished_at = datetime.now() - with patch("core.ops.tencent_trace.utils.TencentTraceUtils.convert_to_span_id") as mock_convert_id: + with patch("dify_trace_tencent.utils.TencentTraceUtils.convert_to_span_id") as mock_convert_id: mock_convert_id.return_value = 404 with patch.object(TencentSpanBuilder, "_get_time_nanoseconds", return_value=100): span = TencentSpanBuilder.build_workflow_tool_span(123, 1, trace_info, node_execution) @@ -350,7 +350,7 @@ class TestTencentSpanBuilder: node_execution.created_at = datetime.now() node_execution.finished_at = datetime.now() - with patch("core.ops.tencent_trace.utils.TencentTraceUtils.convert_to_span_id") as mock_convert_id: + with patch("dify_trace_tencent.utils.TencentTraceUtils.convert_to_span_id") as mock_convert_id: mock_convert_id.return_value = 505 with patch.object(TencentSpanBuilder, "_get_time_nanoseconds", return_value=100): span = TencentSpanBuilder.build_workflow_task_span(123, 1, trace_info, node_execution) diff --git a/api/tests/unit_tests/core/ops/tencent_trace/test_tencent_trace.py b/api/providers/trace/trace-tencent/tests/unit_tests/tencent_trace/test_tencent_trace.py similarity index 89% rename from api/tests/unit_tests/core/ops/tencent_trace/test_tencent_trace.py rename to api/providers/trace/trace-tencent/tests/unit_tests/tencent_trace/test_tencent_trace.py index 7afd0b824a..a91a0aa558 100644 --- a/api/tests/unit_tests/core/ops/tencent_trace/test_tencent_trace.py +++ b/api/providers/trace/trace-tencent/tests/unit_tests/tencent_trace/test_tencent_trace.py @@ -2,8 +2,9 @@ import logging from unittest.mock import MagicMock, patch import pytest +from dify_trace_tencent.config import TencentConfig +from dify_trace_tencent.tencent_trace import TencentDataTrace -from core.ops.entities.config_entity import TencentConfig from core.ops.entities.trace_entity import ( DatasetRetrievalTraceInfo, GenerateNameTraceInfo, @@ -13,7 +14,6 @@ from core.ops.entities.trace_entity import ( ToolTraceInfo, WorkflowTraceInfo, ) -from core.ops.tencent_trace.tencent_trace import TencentDataTrace from graphon.entities import WorkflowNodeExecution from graphon.enums import BuiltinNodeTypes from models import Account, App, TenantAccountJoin @@ -28,19 +28,19 @@ def tencent_config(): @pytest.fixture def mock_trace_client(): - with patch("core.ops.tencent_trace.tencent_trace.TencentTraceClient") as mock: + with patch("dify_trace_tencent.tencent_trace.TencentTraceClient") as mock: yield mock @pytest.fixture def mock_span_builder(): - with patch("core.ops.tencent_trace.tencent_trace.TencentSpanBuilder") as mock: + with patch("dify_trace_tencent.tencent_trace.TencentSpanBuilder") as mock: yield mock @pytest.fixture def mock_trace_utils(): - with patch("core.ops.tencent_trace.tencent_trace.TencentTraceUtils") as mock: + with patch("dify_trace_tencent.tencent_trace.TencentTraceUtils") as mock: yield mock @@ -198,9 +198,9 @@ class TestTencentDataTrace: trace_info.workflow_run_id = "run-id" with patch( - "core.ops.tencent_trace.tencent_trace.TencentTraceUtils.convert_to_trace_id", side_effect=Exception("error") + "dify_trace_tencent.tencent_trace.TencentTraceUtils.convert_to_trace_id", side_effect=Exception("error") ): - with patch("core.ops.tencent_trace.tencent_trace.logger.exception") as mock_log: + with patch("dify_trace_tencent.tencent_trace.logger.exception") as mock_log: tencent_data_trace.workflow_trace(trace_info) mock_log.assert_called_once_with("[Tencent APM] Failed to process workflow trace") @@ -230,9 +230,9 @@ class TestTencentDataTrace: trace_info = MagicMock(spec=MessageTraceInfo) with patch( - "core.ops.tencent_trace.tencent_trace.TencentTraceUtils.convert_to_trace_id", side_effect=Exception("error") + "dify_trace_tencent.tencent_trace.TencentTraceUtils.convert_to_trace_id", side_effect=Exception("error") ): - with patch("core.ops.tencent_trace.tencent_trace.logger.exception") as mock_log: + with patch("dify_trace_tencent.tencent_trace.logger.exception") as mock_log: tencent_data_trace.message_trace(trace_info) mock_log.assert_called_once_with("[Tencent APM] Failed to process message trace") @@ -262,9 +262,9 @@ class TestTencentDataTrace: trace_info.message_id = "msg-id" with patch( - "core.ops.tencent_trace.tencent_trace.TencentTraceUtils.convert_to_span_id", side_effect=Exception("error") + "dify_trace_tencent.tencent_trace.TencentTraceUtils.convert_to_span_id", side_effect=Exception("error") ): - with patch("core.ops.tencent_trace.tencent_trace.logger.exception") as mock_log: + with patch("dify_trace_tencent.tencent_trace.logger.exception") as mock_log: tencent_data_trace.tool_trace(trace_info) mock_log.assert_called_once_with("[Tencent APM] Failed to process tool trace") @@ -294,22 +294,22 @@ class TestTencentDataTrace: trace_info.message_id = "msg-id" with patch( - "core.ops.tencent_trace.tencent_trace.TencentTraceUtils.convert_to_span_id", side_effect=Exception("error") + "dify_trace_tencent.tencent_trace.TencentTraceUtils.convert_to_span_id", side_effect=Exception("error") ): - with patch("core.ops.tencent_trace.tencent_trace.logger.exception") as mock_log: + with patch("dify_trace_tencent.tencent_trace.logger.exception") as mock_log: tencent_data_trace.dataset_retrieval_trace(trace_info) mock_log.assert_called_once_with("[Tencent APM] Failed to process dataset retrieval trace") def test_suggested_question_trace(self, tencent_data_trace): trace_info = MagicMock(spec=SuggestedQuestionTraceInfo) - with patch("core.ops.tencent_trace.tencent_trace.logger.info") as mock_log: + with patch("dify_trace_tencent.tencent_trace.logger.info") as mock_log: tencent_data_trace.suggested_question_trace(trace_info) mock_log.assert_called_once_with("[Tencent APM] Processing suggested question trace") def test_suggested_question_trace_exception(self, tencent_data_trace): trace_info = MagicMock(spec=SuggestedQuestionTraceInfo) - with patch("core.ops.tencent_trace.tencent_trace.logger.info", side_effect=Exception("error")): - with patch("core.ops.tencent_trace.tencent_trace.logger.exception") as mock_log: + with patch("dify_trace_tencent.tencent_trace.logger.info", side_effect=Exception("error")): + with patch("dify_trace_tencent.tencent_trace.logger.exception") as mock_log: tencent_data_trace.suggested_question_trace(trace_info) mock_log.assert_called_once_with("[Tencent APM] Failed to process suggested question trace") @@ -342,7 +342,7 @@ class TestTencentDataTrace: with patch.object(tencent_data_trace, "_get_workflow_node_executions", return_value=[node]): with patch.object(tencent_data_trace, "_build_workflow_node_span", side_effect=Exception("node error")): - with patch("core.ops.tencent_trace.tencent_trace.logger.exception") as mock_log: + with patch("dify_trace_tencent.tencent_trace.logger.exception") as mock_log: tencent_data_trace._process_workflow_nodes(trace_info, 123) # The exception should be caught by the outer handler since convert_to_span_id is called first mock_log.assert_called_once_with("[Tencent APM] Failed to process workflow nodes") @@ -351,7 +351,7 @@ class TestTencentDataTrace: trace_info = MagicMock(spec=WorkflowTraceInfo) mock_trace_utils.convert_to_span_id.side_effect = Exception("outer error") - with patch("core.ops.tencent_trace.tencent_trace.logger.exception") as mock_log: + with patch("dify_trace_tencent.tencent_trace.logger.exception") as mock_log: tencent_data_trace._process_workflow_nodes(trace_info, 123) mock_log.assert_called_once_with("[Tencent APM] Failed to process workflow nodes") @@ -381,7 +381,7 @@ class TestTencentDataTrace: node.id = "n1" mock_span_builder.build_workflow_llm_span.side_effect = Exception("error") - with patch("core.ops.tencent_trace.tencent_trace.logger.debug") as mock_log: + with patch("dify_trace_tencent.tencent_trace.logger.debug") as mock_log: result = tencent_data_trace._build_workflow_node_span(node, 123, MagicMock(), 456) assert result is None mock_log.assert_called_once() @@ -403,15 +403,13 @@ class TestTencentDataTrace: mock_executions = [MagicMock()] - with patch("core.ops.tencent_trace.tencent_trace.db") as mock_db: + with patch("dify_trace_tencent.tencent_trace.db") as mock_db: mock_db.engine = "engine" - with patch("core.ops.tencent_trace.tencent_trace.Session") as mock_session_ctx: + with patch("dify_trace_tencent.tencent_trace.Session") as mock_session_ctx: session = mock_session_ctx.return_value.__enter__.return_value session.scalar.side_effect = [app, account, tenant_join] - with patch( - "core.ops.tencent_trace.tencent_trace.SQLAlchemyWorkflowNodeExecutionRepository" - ) as mock_repo: + with patch("dify_trace_tencent.tencent_trace.SQLAlchemyWorkflowNodeExecutionRepository") as mock_repo: mock_repo.return_value.get_by_workflow_execution.return_value = mock_executions results = tencent_data_trace._get_workflow_node_executions(trace_info) @@ -423,7 +421,7 @@ class TestTencentDataTrace: trace_info = MagicMock(spec=WorkflowTraceInfo) trace_info.metadata = {} - with patch("core.ops.tencent_trace.tencent_trace.logger.exception") as mock_log: + with patch("dify_trace_tencent.tencent_trace.logger.exception") as mock_log: results = tencent_data_trace._get_workflow_node_executions(trace_info) assert results == [] mock_log.assert_called_once() @@ -432,14 +430,14 @@ class TestTencentDataTrace: trace_info = MagicMock(spec=WorkflowTraceInfo) trace_info.metadata = {"app_id": "app-1"} - with patch("core.ops.tencent_trace.tencent_trace.db") as mock_db: + with patch("dify_trace_tencent.tencent_trace.db") as mock_db: mock_db.init_app = MagicMock() # Ensure init_app is mocked mock_db.engine = "engine" - with patch("core.ops.tencent_trace.tencent_trace.Session") as mock_session_ctx: + with patch("dify_trace_tencent.tencent_trace.Session") as mock_session_ctx: session = mock_session_ctx.return_value.__enter__.return_value session.scalar.return_value = None - with patch("core.ops.tencent_trace.tencent_trace.logger.exception") as mock_log: + with patch("dify_trace_tencent.tencent_trace.logger.exception") as mock_log: results = tencent_data_trace._get_workflow_node_executions(trace_info) assert results == [] mock_log.assert_called_once() @@ -449,8 +447,8 @@ class TestTencentDataTrace: trace_info.tenant_id = "tenant-1" trace_info.metadata = {"user_id": "user-1"} - with patch("core.ops.tencent_trace.tencent_trace.sessionmaker", side_effect=Exception("Database error")): - with patch("core.ops.tencent_trace.tencent_trace.db") as mock_db: + with patch("dify_trace_tencent.tencent_trace.sessionmaker", side_effect=Exception("Database error")): + with patch("dify_trace_tencent.tencent_trace.db") as mock_db: mock_db.init_app = MagicMock() mock_db.engine = MagicMock() @@ -476,8 +474,8 @@ class TestTencentDataTrace: trace_info.tenant_id = "t" trace_info.metadata = {"user_id": "u"} - with patch("core.ops.tencent_trace.tencent_trace.sessionmaker", side_effect=Exception("error")): - with patch("core.ops.tencent_trace.tencent_trace.logger.exception") as mock_log: + with patch("dify_trace_tencent.tencent_trace.sessionmaker", side_effect=Exception("error")): + with patch("dify_trace_tencent.tencent_trace.logger.exception") as mock_log: user_id = tencent_data_trace._get_user_id(trace_info) assert user_id == "unknown" mock_log.assert_called_once_with("[Tencent APM] Failed to get user ID") @@ -519,7 +517,7 @@ class TestTencentDataTrace: node.process_data = None node.outputs = None - with patch("core.ops.tencent_trace.tencent_trace.logger.debug") as mock_log: + with patch("dify_trace_tencent.tencent_trace.logger.debug") as mock_log: tencent_data_trace._record_llm_metrics(node) # Should not crash @@ -557,7 +555,7 @@ class TestTencentDataTrace: trace_info = MagicMock(spec=MessageTraceInfo) trace_info.metadata = None - with patch("core.ops.tencent_trace.tencent_trace.logger.debug") as mock_log: + with patch("dify_trace_tencent.tencent_trace.logger.debug") as mock_log: tencent_data_trace._record_message_llm_metrics(trace_info) # Should not crash @@ -609,7 +607,7 @@ class TestTencentDataTrace: trace_info = MagicMock(spec=WorkflowTraceInfo) trace_info.start_time = MagicMock() # This might cause total_seconds() to fail if not mocked right - with patch("core.ops.tencent_trace.tencent_trace.logger.debug") as mock_log: + with patch("dify_trace_tencent.tencent_trace.logger.debug") as mock_log: tencent_data_trace._record_workflow_trace_duration(trace_info) def test_record_message_trace_duration(self, tencent_data_trace): @@ -631,7 +629,7 @@ class TestTencentDataTrace: trace_info = MagicMock(spec=MessageTraceInfo) trace_info.start_time = None - with patch("core.ops.tencent_trace.tencent_trace.logger.debug") as mock_log: + with patch("dify_trace_tencent.tencent_trace.logger.debug") as mock_log: tencent_data_trace._record_message_trace_duration(trace_info) def test_del(self, tencent_data_trace): @@ -641,6 +639,6 @@ class TestTencentDataTrace: def test_del_exception(self, tencent_data_trace): tencent_data_trace.trace_client.shutdown.side_effect = Exception("error") - with patch("core.ops.tencent_trace.tencent_trace.logger.exception") as mock_log: + with patch("dify_trace_tencent.tencent_trace.logger.exception") as mock_log: tencent_data_trace.__del__() mock_log.assert_called_once_with("[Tencent APM] Failed to shutdown trace client during cleanup") diff --git a/api/tests/unit_tests/core/ops/tencent_trace/test_tencent_trace_utils.py b/api/providers/trace/trace-tencent/tests/unit_tests/tencent_trace/test_tencent_trace_utils.py similarity index 88% rename from api/tests/unit_tests/core/ops/tencent_trace/test_tencent_trace_utils.py rename to api/providers/trace/trace-tencent/tests/unit_tests/tencent_trace/test_tencent_trace_utils.py index ef28d18e20..63c6d680d7 100644 --- a/api/tests/unit_tests/core/ops/tencent_trace/test_tencent_trace_utils.py +++ b/api/providers/trace/trace-tencent/tests/unit_tests/tencent_trace/test_tencent_trace_utils.py @@ -8,10 +8,9 @@ from datetime import UTC, datetime from unittest.mock import patch import pytest +from dify_trace_tencent.utils import TencentTraceUtils from opentelemetry.trace import Link, TraceFlags -from core.ops.tencent_trace.utils import TencentTraceUtils - def test_convert_to_trace_id_with_valid_uuid() -> None: uuid_str = "12345678-1234-5678-1234-567812345678" @@ -20,7 +19,7 @@ def test_convert_to_trace_id_with_valid_uuid() -> None: def test_convert_to_trace_id_uses_uuid4_when_none() -> None: expected_uuid = uuid.UUID("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") - with patch("core.ops.tencent_trace.utils.uuid.uuid4", return_value=expected_uuid) as uuid4_mock: + with patch("dify_trace_tencent.utils.uuid.uuid4", return_value=expected_uuid) as uuid4_mock: assert TencentTraceUtils.convert_to_trace_id(None) == expected_uuid.int uuid4_mock.assert_called_once() @@ -45,7 +44,7 @@ def test_convert_to_span_id_is_deterministic_and_sensitive_to_type() -> None: def test_convert_to_span_id_uses_uuid4_when_none() -> None: expected_uuid = uuid.UUID("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb") - with patch("core.ops.tencent_trace.utils.uuid.uuid4", return_value=expected_uuid) as uuid4_mock: + with patch("dify_trace_tencent.utils.uuid.uuid4", return_value=expected_uuid) as uuid4_mock: span_id = TencentTraceUtils.convert_to_span_id(None, "workflow") assert isinstance(span_id, int) uuid4_mock.assert_called_once() @@ -58,7 +57,7 @@ def test_convert_to_span_id_raises_value_error_for_invalid_uuid() -> None: def test_generate_span_id_skips_invalid_span_id() -> None: with patch( - "core.ops.tencent_trace.utils.random.getrandbits", + "dify_trace_tencent.utils.random.getrandbits", side_effect=[TencentTraceUtils.INVALID_SPAN_ID, 42], ) as bits_mock: assert TencentTraceUtils.generate_span_id() == 42 @@ -75,7 +74,7 @@ def test_convert_datetime_to_nanoseconds_uses_now_when_none() -> None: fixed = datetime(2024, 1, 2, 3, 4, 5, tzinfo=UTC) expected = int(fixed.timestamp() * 1e9) - with patch("core.ops.tencent_trace.utils.datetime") as datetime_mock: + with patch("dify_trace_tencent.utils.datetime") as datetime_mock: datetime_mock.now.return_value = fixed assert TencentTraceUtils.convert_datetime_to_nanoseconds(None) == expected datetime_mock.now.assert_called_once() @@ -100,7 +99,7 @@ def test_create_link_accepts_hex_or_uuid(trace_id_str: str, expected_trace_id: i @pytest.mark.parametrize("trace_id_str", ["g" * 32, "not-a-uuid", None]) def test_create_link_falls_back_to_uuid4(trace_id_str: object) -> None: fallback_uuid = uuid.UUID("dddddddd-dddd-dddd-dddd-dddddddddddd") - with patch("core.ops.tencent_trace.utils.uuid.uuid4", return_value=fallback_uuid) as uuid4_mock: + with patch("dify_trace_tencent.utils.uuid.uuid4", return_value=fallback_uuid) as uuid4_mock: link = TencentTraceUtils.create_link(trace_id_str) # type: ignore[arg-type] assert link.context.trace_id == fallback_uuid.int uuid4_mock.assert_called_once() diff --git a/api/providers/trace/trace-weave/pyproject.toml b/api/providers/trace/trace-weave/pyproject.toml new file mode 100644 index 0000000000..ba449f2a93 --- /dev/null +++ b/api/providers/trace/trace-weave/pyproject.toml @@ -0,0 +1,10 @@ +[project] +name = "dify-trace-weave" +version = "0.0.1" +dependencies = [ + "weave>=0.52.36", +] +description = "Dify ops tracing provider (Weave)." + +[tool.setuptools.packages.find] +where = ["src"] diff --git a/api/providers/trace/trace-weave/src/dify_trace_weave/__init__.py b/api/providers/trace/trace-weave/src/dify_trace_weave/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api/providers/trace/trace-weave/src/dify_trace_weave/config.py b/api/providers/trace/trace-weave/src/dify_trace_weave/config.py new file mode 100644 index 0000000000..5942bd57fe --- /dev/null +++ b/api/providers/trace/trace-weave/src/dify_trace_weave/config.py @@ -0,0 +1,29 @@ +from pydantic import ValidationInfo, field_validator + +from core.ops.entities.config_entity import BaseTracingConfig +from core.ops.utils import validate_url + + +class WeaveConfig(BaseTracingConfig): + """ + Model class for Weave tracing config. + """ + + api_key: str + entity: str | None = None + project: str + endpoint: str = "https://trace.wandb.ai" + host: str | None = None + + @field_validator("endpoint") + @classmethod + def endpoint_validator(cls, v, info: ValidationInfo): + # Weave only allows HTTPS for endpoint + return validate_url(v, "https://trace.wandb.ai", allowed_schemes=("https",)) + + @field_validator("host") + @classmethod + def host_validator(cls, v, info: ValidationInfo): + if v is not None and v.strip() != "": + return validate_url(v, v, allowed_schemes=("https", "http")) + return v diff --git a/api/providers/trace/trace-weave/src/dify_trace_weave/entities/__init__.py b/api/providers/trace/trace-weave/src/dify_trace_weave/entities/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api/core/ops/weave_trace/entities/weave_trace_entity.py b/api/providers/trace/trace-weave/src/dify_trace_weave/entities/weave_trace_entity.py similarity index 100% rename from api/core/ops/weave_trace/entities/weave_trace_entity.py rename to api/providers/trace/trace-weave/src/dify_trace_weave/entities/weave_trace_entity.py diff --git a/api/providers/trace/trace-weave/src/dify_trace_weave/py.typed b/api/providers/trace/trace-weave/src/dify_trace_weave/py.typed new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api/core/ops/weave_trace/weave_trace.py b/api/providers/trace/trace-weave/src/dify_trace_weave/weave_trace.py similarity index 99% rename from api/core/ops/weave_trace/weave_trace.py rename to api/providers/trace/trace-weave/src/dify_trace_weave/weave_trace.py index f79544f1c7..4292cbf0f1 100644 --- a/api/core/ops/weave_trace/weave_trace.py +++ b/api/providers/trace/trace-weave/src/dify_trace_weave/weave_trace.py @@ -17,7 +17,6 @@ from weave.trace_server.trace_server_interface import ( ) from core.ops.base_trace_instance import BaseTraceInstance -from core.ops.entities.config_entity import WeaveConfig from core.ops.entities.trace_entity import ( BaseTraceInfo, DatasetRetrievalTraceInfo, @@ -29,8 +28,9 @@ from core.ops.entities.trace_entity import ( TraceTaskName, WorkflowTraceInfo, ) -from core.ops.weave_trace.entities.weave_trace_entity import WeaveTraceModel from core.repositories import DifyCoreRepositoryFactory +from dify_trace_weave.config import WeaveConfig +from dify_trace_weave.entities.weave_trace_entity import WeaveTraceModel from extensions.ext_database import db from graphon.enums import BuiltinNodeTypes, WorkflowNodeExecutionMetadataKey from models import EndUser, MessageFile, WorkflowNodeExecutionTriggeredFrom diff --git a/api/providers/trace/trace-weave/tests/unit_tests/test_config_entity.py b/api/providers/trace/trace-weave/tests/unit_tests/test_config_entity.py new file mode 100644 index 0000000000..eeb1fe1d87 --- /dev/null +++ b/api/providers/trace/trace-weave/tests/unit_tests/test_config_entity.py @@ -0,0 +1,61 @@ +import pytest +from dify_trace_weave.config import WeaveConfig +from pydantic import ValidationError + + +class TestWeaveConfig: + """Test cases for WeaveConfig""" + + def test_valid_config(self): + """Test valid Weave configuration""" + config = WeaveConfig( + api_key="test_key", + entity="test_entity", + project="test_project", + endpoint="https://custom.wandb.ai", + host="https://custom.host.com", + ) + assert config.api_key == "test_key" + assert config.entity == "test_entity" + assert config.project == "test_project" + assert config.endpoint == "https://custom.wandb.ai" + assert config.host == "https://custom.host.com" + + def test_default_values(self): + """Test default values are set correctly""" + config = WeaveConfig(api_key="key", project="project") + assert config.entity is None + assert config.endpoint == "https://trace.wandb.ai" + assert config.host is None + + def test_missing_required_fields(self): + """Test that required fields are enforced""" + with pytest.raises(ValidationError): + WeaveConfig() + + with pytest.raises(ValidationError): + WeaveConfig(api_key="key") + + with pytest.raises(ValidationError): + WeaveConfig(project="project") + + def test_endpoint_validation_https_only(self): + """Test endpoint validation only allows HTTPS""" + with pytest.raises(ValidationError, match="URL scheme must be one of"): + WeaveConfig(api_key="key", project="project", endpoint="http://insecure.wandb.ai") + + def test_host_validation_optional(self): + """Test host validation is optional but validates when provided""" + config = WeaveConfig(api_key="key", project="project", host=None) + assert config.host is None + + config = WeaveConfig(api_key="key", project="project", host="") + assert config.host == "" + + config = WeaveConfig(api_key="key", project="project", host="https://valid.host.com") + assert config.host == "https://valid.host.com" + + def test_host_validation_invalid_scheme(self): + """Test host validation rejects invalid schemes when provided""" + with pytest.raises(ValidationError, match="URL scheme must be one of"): + WeaveConfig(api_key="key", project="project", host="ftp://invalid.host.com") diff --git a/api/tests/unit_tests/core/ops/weave_trace/test_weave_trace.py b/api/providers/trace/trace-weave/tests/unit_tests/weave_trace/test_weave_trace.py similarity index 97% rename from api/tests/unit_tests/core/ops/weave_trace/test_weave_trace.py rename to api/providers/trace/trace-weave/tests/unit_tests/weave_trace/test_weave_trace.py index 531c7de05f..6028d0c550 100644 --- a/api/tests/unit_tests/core/ops/weave_trace/test_weave_trace.py +++ b/api/providers/trace/trace-weave/tests/unit_tests/weave_trace/test_weave_trace.py @@ -1,4 +1,4 @@ -"""Comprehensive tests for core.ops.weave_trace.weave_trace module.""" +"""Comprehensive tests for dify_trace_weave.weave_trace module.""" from __future__ import annotations @@ -7,9 +7,11 @@ from types import SimpleNamespace from unittest.mock import MagicMock, patch import pytest +from dify_trace_weave.config import WeaveConfig +from dify_trace_weave.entities.weave_trace_entity import WeaveTraceModel +from dify_trace_weave.weave_trace import WeaveDataTrace from weave.trace_server.trace_server_interface import TraceStatus -from core.ops.entities.config_entity import WeaveConfig from core.ops.entities.trace_entity import ( DatasetRetrievalTraceInfo, GenerateNameTraceInfo, @@ -20,8 +22,6 @@ from core.ops.entities.trace_entity import ( TraceTaskName, WorkflowTraceInfo, ) -from core.ops.weave_trace.entities.weave_trace_entity import WeaveTraceModel -from core.ops.weave_trace.weave_trace import WeaveDataTrace from graphon.enums import BuiltinNodeTypes, WorkflowNodeExecutionMetadataKey # ── Helpers ────────────────────────────────────────────────────────────────── @@ -191,14 +191,14 @@ def _make_node(**overrides): @pytest.fixture def mock_wandb(): - with patch("core.ops.weave_trace.weave_trace.wandb") as mock: + with patch("dify_trace_weave.weave_trace.wandb") as mock: mock.login.return_value = True yield mock @pytest.fixture def mock_weave(): - with patch("core.ops.weave_trace.weave_trace.weave") as mock: + with patch("dify_trace_weave.weave_trace.weave") as mock: client = MagicMock() client.entity = "my-entity" client.project = "my-project" @@ -307,7 +307,7 @@ class TestGetProjectUrl: monkeypatch.setattr(trace_instance, "entity", None) monkeypatch.setattr(trace_instance, "project_name", None) # Force an error by making string formatting fail - with patch("core.ops.weave_trace.weave_trace.logger") as mock_logger: + with patch("dify_trace_weave.weave_trace.logger") as mock_logger: # Simulate exception via property original_entity = trace_instance.entity trace_instance.entity = None @@ -594,9 +594,9 @@ class TestWorkflowTrace: mock_factory = MagicMock() mock_factory.create_workflow_node_execution_repository.return_value = repo - monkeypatch.setattr("core.ops.weave_trace.weave_trace.DifyCoreRepositoryFactory", mock_factory) - monkeypatch.setattr("core.ops.weave_trace.weave_trace.sessionmaker", lambda bind: MagicMock()) - monkeypatch.setattr("core.ops.weave_trace.weave_trace.db", MagicMock(engine="engine")) + monkeypatch.setattr("dify_trace_weave.weave_trace.DifyCoreRepositoryFactory", mock_factory) + monkeypatch.setattr("dify_trace_weave.weave_trace.sessionmaker", lambda bind: MagicMock()) + monkeypatch.setattr("dify_trace_weave.weave_trace.db", MagicMock(engine="engine")) return repo def test_workflow_trace_no_nodes_no_message_id(self, trace_instance, monkeypatch): @@ -703,8 +703,8 @@ class TestWorkflowTrace: def test_workflow_trace_missing_app_id_raises(self, trace_instance, monkeypatch): """Raises ValueError when app_id is missing from metadata.""" - monkeypatch.setattr("core.ops.weave_trace.weave_trace.sessionmaker", lambda bind: MagicMock()) - monkeypatch.setattr("core.ops.weave_trace.weave_trace.db", MagicMock(engine="engine")) + monkeypatch.setattr("dify_trace_weave.weave_trace.sessionmaker", lambda bind: MagicMock()) + monkeypatch.setattr("dify_trace_weave.weave_trace.db", MagicMock(engine="engine")) trace_info = _make_workflow_trace_info( message_id=None, @@ -802,7 +802,7 @@ class TestMessageTrace: def test_basic_message_trace(self, trace_instance, monkeypatch): """message_trace creates message run and llm child run.""" monkeypatch.setattr( - "core.ops.weave_trace.weave_trace.db.session.get", + "dify_trace_weave.weave_trace.db.session.get", lambda model, pk: None, ) @@ -824,7 +824,7 @@ class TestMessageTrace: mock_db = MagicMock() mock_db.session.get.return_value = None - monkeypatch.setattr("core.ops.weave_trace.weave_trace.db", mock_db) + monkeypatch.setattr("dify_trace_weave.weave_trace.db", mock_db) trace_instance.start_call = MagicMock() trace_instance.finish_call = MagicMock() @@ -846,7 +846,7 @@ class TestMessageTrace: mock_db = MagicMock() mock_db.session.get.return_value = end_user - monkeypatch.setattr("core.ops.weave_trace.weave_trace.db", mock_db) + monkeypatch.setattr("dify_trace_weave.weave_trace.db", mock_db) trace_instance.start_call = MagicMock() trace_instance.finish_call = MagicMock() @@ -866,7 +866,7 @@ class TestMessageTrace: """message_trace handles when from_end_user_id is None.""" mock_db = MagicMock() mock_db.session.get.return_value = None - monkeypatch.setattr("core.ops.weave_trace.weave_trace.db", mock_db) + monkeypatch.setattr("dify_trace_weave.weave_trace.db", mock_db) trace_instance.start_call = MagicMock() trace_instance.finish_call = MagicMock() @@ -884,7 +884,7 @@ class TestMessageTrace: """trace_id falls back to message_id when trace_id is None.""" mock_db = MagicMock() mock_db.session.get.return_value = None - monkeypatch.setattr("core.ops.weave_trace.weave_trace.db", mock_db) + monkeypatch.setattr("dify_trace_weave.weave_trace.db", mock_db) trace_instance.start_call = MagicMock() trace_instance.finish_call = MagicMock() @@ -899,7 +899,7 @@ class TestMessageTrace: """message_trace handles file_list=None gracefully.""" mock_db = MagicMock() mock_db.session.get.return_value = None - monkeypatch.setattr("core.ops.weave_trace.weave_trace.db", mock_db) + monkeypatch.setattr("dify_trace_weave.weave_trace.db", mock_db) trace_instance.start_call = MagicMock() trace_instance.finish_call = MagicMock() diff --git a/api/pyproject.toml b/api/pyproject.toml index a1ceea181e..12b8b3d782 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -32,9 +32,6 @@ dependencies = [ "flask-restx>=1.3.2,<2.0.0", "google-cloud-aiplatform>=1.147.0,<2.0.0", "httpx[socks]>=0.28.1,<1.0.0", - "langfuse>=4.2.0,<5.0.0", - "langsmith>=0.7.31,<1.0.0", - "mlflow-skinny>=3.11.1,<4.0.0", "opentelemetry-distro>=0.62b0,<1.0.0", "opentelemetry-instrumentation-celery>=0.62b0,<1.0.0", "opentelemetry-instrumentation-flask>=0.62b0,<1.0.0", @@ -44,15 +41,12 @@ dependencies = [ "opentelemetry-propagator-b3>=1.41.0,<2.0.0", "readabilipy>=0.3.0,<1.0.0", "resend>=2.27.0,<3.0.0", - "weave>=0.52.36,<1.0.0", # Emerging: newer and fast-moving, use compatible pins - "arize-phoenix-otel~=0.15.0", "fastopenapi[flask]~=0.7.0", "graphon~=0.1.2", "httpx-sse~=0.4.0", "json-repair~=0.59.2", - "opik~=1.11.2", ] # Before adding new dependency, consider place it in # alphabet order (a-z) and suitable group. @@ -61,8 +55,8 @@ dependencies = [ packages = [] [tool.uv.workspace] -members = ["providers/vdb/*"] -exclude = ["providers/vdb/__pycache__"] +members = ["providers/vdb/*", "providers/trace/*"] +exclude = ["providers/vdb/__pycache__", "providers/trace/__pycache__"] [tool.uv.sources] dify-vdb-alibabacloud-mysql = { workspace = true } @@ -95,9 +89,17 @@ dify-vdb-upstash = { workspace = true } dify-vdb-vastbase = { workspace = true } dify-vdb-vikingdb = { workspace = true } dify-vdb-weaviate = { workspace = true } +dify-trace-aliyun = { workspace = true } +dify-trace-arize-phoenix = { workspace = true } +dify-trace-langfuse = { workspace = true } +dify-trace-langsmith = { workspace = true } +dify-trace-mlflow = { workspace = true } +dify-trace-opik = { workspace = true } +dify-trace-tencent = { workspace = true } +dify-trace-weave = { workspace = true } [tool.uv] -default-groups = ["storage", "tools", "vdb-all"] +default-groups = ["storage", "tools", "vdb-all", "trace-all"] package = false override-dependencies = [ "pyarrow>=18.0.0", @@ -266,6 +268,25 @@ vdb-weaviate = ["dify-vdb-weaviate"] # Optional client used by some tests / integrations (not a vector backend plugin) vdb-xinference = ["xinference-client>=2.4.0"] +trace-all = [ + "dify-trace-aliyun", + "dify-trace-arize-phoenix", + "dify-trace-langfuse", + "dify-trace-langsmith", + "dify-trace-mlflow", + "dify-trace-opik", + "dify-trace-tencent", + "dify-trace-weave", +] +trace-aliyun = ["dify-trace-aliyun"] +trace-arize-phoenix = ["dify-trace-arize-phoenix"] +trace-langfuse = ["dify-trace-langfuse"] +trace-langsmith = ["dify-trace-langsmith"] +trace-mlflow = ["dify-trace-mlflow"] +trace-opik = ["dify-trace-opik"] +trace-tencent = ["dify-trace-tencent"] +trace-weave = ["dify-trace-weave"] + [tool.pyrefly] project-includes = ["."] project-excludes = [".venv", "migrations/"] diff --git a/api/pyrefly-local-excludes.txt b/api/pyrefly-local-excludes.txt index 3e5ece1fcf..fbbca24558 100644 --- a/api/pyrefly-local-excludes.txt +++ b/api/pyrefly-local-excludes.txt @@ -34,12 +34,12 @@ core/external_data_tool/api/api.py core/llm_generator/llm_generator.py core/llm_generator/output_parser/structured_output.py core/mcp/mcp_client.py -core/ops/aliyun_trace/data_exporter/traceclient.py -core/ops/arize_phoenix_trace/arize_phoenix_trace.py -core/ops/mlflow_trace/mlflow_trace.py +providers/trace/trace-aliyun/src/dify_trace_aliyun/data_exporter/traceclient.py +providers/trace/trace-arize-phoenix/src/dify_trace_arize_phoenix/arize_phoenix_trace.py +providers/trace/trace-mlflow/src/dify_trace_mlflow/mlflow_trace.py core/ops/ops_trace_manager.py -core/ops/tencent_trace/client.py -core/ops/tencent_trace/utils.py +providers/trace/trace-tencent/src/dify_trace_tencent/client.py +providers/trace/trace-tencent/src/dify_trace_tencent/utils.py core/plugin/backwards_invocation/base.py core/plugin/backwards_invocation/model.py core/prompt/utils/extract_thread_messages.py diff --git a/api/pyrightconfig.json b/api/pyrightconfig.json index c4582e891d..ac0e2a3a53 100644 --- a/api/pyrightconfig.json +++ b/api/pyrightconfig.json @@ -5,7 +5,8 @@ ".venv", "migrations/", "core/rag", - "providers/", + "providers/vdb/", + "providers/trace/*/tests", ], "typeCheckingMode": "strict", "allowedUntypedLibraries": [ diff --git a/api/tests/unit_tests/core/ops/test_config_entity.py b/api/tests/unit_tests/core/ops/test_config_entity.py index 2cbff54c42..69650c85cc 100644 --- a/api/tests/unit_tests/core/ops/test_config_entity.py +++ b/api/tests/unit_tests/core/ops/test_config_entity.py @@ -1,16 +1,11 @@ -import pytest -from pydantic import ValidationError +from dify_trace_aliyun.config import AliyunConfig +from dify_trace_arize_phoenix.config import ArizeConfig, PhoenixConfig +from dify_trace_langfuse.config import LangfuseConfig +from dify_trace_langsmith.config import LangSmithConfig +from dify_trace_opik.config import OpikConfig +from dify_trace_weave.config import WeaveConfig -from core.ops.entities.config_entity import ( - AliyunConfig, - ArizeConfig, - LangfuseConfig, - LangSmithConfig, - OpikConfig, - PhoenixConfig, - TracingProviderEnum, - WeaveConfig, -) +from core.ops.entities.config_entity import TracingProviderEnum class TestTracingProviderEnum: @@ -27,349 +22,8 @@ class TestTracingProviderEnum: assert TracingProviderEnum.ALIYUN == "aliyun" -class TestArizeConfig: - """Test cases for ArizeConfig""" - - def test_valid_config(self): - """Test valid Arize configuration""" - config = ArizeConfig( - api_key="test_key", space_id="test_space", project="test_project", endpoint="https://custom.arize.com" - ) - assert config.api_key == "test_key" - assert config.space_id == "test_space" - assert config.project == "test_project" - assert config.endpoint == "https://custom.arize.com" - - def test_default_values(self): - """Test default values are set correctly""" - config = ArizeConfig() - assert config.api_key is None - assert config.space_id is None - assert config.project is None - assert config.endpoint == "https://otlp.arize.com" - - def test_project_validation_empty(self): - """Test project validation with empty value""" - config = ArizeConfig(project="") - assert config.project == "default" - - def test_project_validation_none(self): - """Test project validation with None value""" - config = ArizeConfig(project=None) - assert config.project == "default" - - def test_endpoint_validation_empty(self): - """Test endpoint validation with empty value""" - config = ArizeConfig(endpoint="") - assert config.endpoint == "https://otlp.arize.com" - - def test_endpoint_validation_with_path(self): - """Test endpoint validation normalizes URL by removing path""" - config = ArizeConfig(endpoint="https://custom.arize.com/api/v1") - assert config.endpoint == "https://custom.arize.com" - - def test_endpoint_validation_invalid_scheme(self): - """Test endpoint validation rejects invalid schemes""" - with pytest.raises(ValidationError, match="URL scheme must be one of"): - ArizeConfig(endpoint="ftp://invalid.com") - - def test_endpoint_validation_no_scheme(self): - """Test endpoint validation rejects URLs without scheme""" - with pytest.raises(ValidationError, match="URL scheme must be one of"): - ArizeConfig(endpoint="invalid.com") - - -class TestPhoenixConfig: - """Test cases for PhoenixConfig""" - - def test_valid_config(self): - """Test valid Phoenix configuration""" - config = PhoenixConfig(api_key="test_key", project="test_project", endpoint="https://custom.phoenix.com") - assert config.api_key == "test_key" - assert config.project == "test_project" - assert config.endpoint == "https://custom.phoenix.com" - - def test_default_values(self): - """Test default values are set correctly""" - config = PhoenixConfig() - assert config.api_key is None - assert config.project is None - assert config.endpoint == "https://app.phoenix.arize.com" - - def test_project_validation_empty(self): - """Test project validation with empty value""" - config = PhoenixConfig(project="") - assert config.project == "default" - - def test_endpoint_validation_with_path(self): - """Test endpoint validation with path""" - config = PhoenixConfig(endpoint="https://app.phoenix.arize.com/s/dify-integration") - assert config.endpoint == "https://app.phoenix.arize.com/s/dify-integration" - - def test_endpoint_validation_without_path(self): - """Test endpoint validation without path""" - config = PhoenixConfig(endpoint="https://app.phoenix.arize.com") - assert config.endpoint == "https://app.phoenix.arize.com" - - -class TestLangfuseConfig: - """Test cases for LangfuseConfig""" - - def test_valid_config(self): - """Test valid Langfuse configuration""" - config = LangfuseConfig(public_key="public_key", secret_key="secret_key", host="https://custom.langfuse.com") - assert config.public_key == "public_key" - assert config.secret_key == "secret_key" - assert config.host == "https://custom.langfuse.com" - - def test_valid_config_with_path(self): - host = "https://custom.langfuse.com/api/v1" - config = LangfuseConfig(public_key="public_key", secret_key="secret_key", host=host) - assert config.public_key == "public_key" - assert config.secret_key == "secret_key" - assert config.host == host - - def test_default_values(self): - """Test default values are set correctly""" - config = LangfuseConfig(public_key="public", secret_key="secret") - assert config.host == "https://api.langfuse.com" - - def test_missing_required_fields(self): - """Test that required fields are enforced""" - with pytest.raises(ValidationError): - LangfuseConfig() - - with pytest.raises(ValidationError): - LangfuseConfig(public_key="public") - - with pytest.raises(ValidationError): - LangfuseConfig(secret_key="secret") - - def test_host_validation_empty(self): - """Test host validation with empty value""" - config = LangfuseConfig(public_key="public", secret_key="secret", host="") - assert config.host == "https://api.langfuse.com" - - -class TestLangSmithConfig: - """Test cases for LangSmithConfig""" - - def test_valid_config(self): - """Test valid LangSmith configuration""" - config = LangSmithConfig(api_key="test_key", project="test_project", endpoint="https://custom.smith.com") - assert config.api_key == "test_key" - assert config.project == "test_project" - assert config.endpoint == "https://custom.smith.com" - - def test_default_values(self): - """Test default values are set correctly""" - config = LangSmithConfig(api_key="key", project="project") - assert config.endpoint == "https://api.smith.langchain.com" - - def test_missing_required_fields(self): - """Test that required fields are enforced""" - with pytest.raises(ValidationError): - LangSmithConfig() - - with pytest.raises(ValidationError): - LangSmithConfig(api_key="key") - - with pytest.raises(ValidationError): - LangSmithConfig(project="project") - - def test_endpoint_validation_https_only(self): - """Test endpoint validation only allows HTTPS""" - with pytest.raises(ValidationError, match="URL scheme must be one of"): - LangSmithConfig(api_key="key", project="project", endpoint="http://insecure.com") - - -class TestOpikConfig: - """Test cases for OpikConfig""" - - def test_valid_config(self): - """Test valid Opik configuration""" - config = OpikConfig( - api_key="test_key", - project="test_project", - workspace="test_workspace", - url="https://custom.comet.com/opik/api/", - ) - assert config.api_key == "test_key" - assert config.project == "test_project" - assert config.workspace == "test_workspace" - assert config.url == "https://custom.comet.com/opik/api/" - - def test_default_values(self): - """Test default values are set correctly""" - config = OpikConfig() - assert config.api_key is None - assert config.project is None - assert config.workspace is None - assert config.url == "https://www.comet.com/opik/api/" - - def test_project_validation_empty(self): - """Test project validation with empty value""" - config = OpikConfig(project="") - assert config.project == "Default Project" - - def test_url_validation_empty(self): - """Test URL validation with empty value""" - config = OpikConfig(url="") - assert config.url == "https://www.comet.com/opik/api/" - - def test_url_validation_missing_suffix(self): - """Test URL validation requires /api/ suffix""" - with pytest.raises(ValidationError, match="URL should end with /api/"): - OpikConfig(url="https://custom.comet.com/opik/") - - def test_url_validation_invalid_scheme(self): - """Test URL validation rejects invalid schemes""" - with pytest.raises(ValidationError, match="URL must start with https:// or http://"): - OpikConfig(url="ftp://custom.comet.com/opik/api/") - - -class TestWeaveConfig: - """Test cases for WeaveConfig""" - - def test_valid_config(self): - """Test valid Weave configuration""" - config = WeaveConfig( - api_key="test_key", - entity="test_entity", - project="test_project", - endpoint="https://custom.wandb.ai", - host="https://custom.host.com", - ) - assert config.api_key == "test_key" - assert config.entity == "test_entity" - assert config.project == "test_project" - assert config.endpoint == "https://custom.wandb.ai" - assert config.host == "https://custom.host.com" - - def test_default_values(self): - """Test default values are set correctly""" - config = WeaveConfig(api_key="key", project="project") - assert config.entity is None - assert config.endpoint == "https://trace.wandb.ai" - assert config.host is None - - def test_missing_required_fields(self): - """Test that required fields are enforced""" - with pytest.raises(ValidationError): - WeaveConfig() - - with pytest.raises(ValidationError): - WeaveConfig(api_key="key") - - with pytest.raises(ValidationError): - WeaveConfig(project="project") - - def test_endpoint_validation_https_only(self): - """Test endpoint validation only allows HTTPS""" - with pytest.raises(ValidationError, match="URL scheme must be one of"): - WeaveConfig(api_key="key", project="project", endpoint="http://insecure.wandb.ai") - - def test_host_validation_optional(self): - """Test host validation is optional but validates when provided""" - config = WeaveConfig(api_key="key", project="project", host=None) - assert config.host is None - - config = WeaveConfig(api_key="key", project="project", host="") - assert config.host == "" - - config = WeaveConfig(api_key="key", project="project", host="https://valid.host.com") - assert config.host == "https://valid.host.com" - - def test_host_validation_invalid_scheme(self): - """Test host validation rejects invalid schemes when provided""" - with pytest.raises(ValidationError, match="URL scheme must be one of"): - WeaveConfig(api_key="key", project="project", host="ftp://invalid.host.com") - - -class TestAliyunConfig: - """Test cases for AliyunConfig""" - - def test_valid_config(self): - """Test valid Aliyun configuration""" - config = AliyunConfig( - app_name="test_app", - license_key="test_license_key", - endpoint="https://custom.tracing-analysis-dc-hz.aliyuncs.com", - ) - assert config.app_name == "test_app" - assert config.license_key == "test_license_key" - assert config.endpoint == "https://custom.tracing-analysis-dc-hz.aliyuncs.com" - - def test_default_values(self): - """Test default values are set correctly""" - config = AliyunConfig(license_key="test_license", endpoint="https://tracing-analysis-dc-hz.aliyuncs.com") - assert config.app_name == "dify_app" - - def test_missing_required_fields(self): - """Test that required fields are enforced""" - with pytest.raises(ValidationError): - AliyunConfig() - - with pytest.raises(ValidationError): - AliyunConfig(license_key="test_license") - - with pytest.raises(ValidationError): - AliyunConfig(endpoint="https://tracing-analysis-dc-hz.aliyuncs.com") - - def test_app_name_validation_empty(self): - """Test app_name validation with empty value""" - config = AliyunConfig( - license_key="test_license", endpoint="https://tracing-analysis-dc-hz.aliyuncs.com", app_name="" - ) - assert config.app_name == "dify_app" - - def test_endpoint_validation_empty(self): - """Test endpoint validation with empty value""" - config = AliyunConfig(license_key="test_license", endpoint="") - assert config.endpoint == "https://tracing-analysis-dc-hz.aliyuncs.com" - - def test_endpoint_validation_with_path(self): - """Test endpoint validation preserves path for Aliyun endpoints""" - config = AliyunConfig( - license_key="test_license", endpoint="https://tracing-analysis-dc-hz.aliyuncs.com/api/v1/traces" - ) - assert config.endpoint == "https://tracing-analysis-dc-hz.aliyuncs.com/api/v1/traces" - - def test_endpoint_validation_invalid_scheme(self): - """Test endpoint validation rejects invalid schemes""" - with pytest.raises(ValidationError, match="URL must start with https:// or http://"): - AliyunConfig(license_key="test_license", endpoint="ftp://invalid.tracing-analysis-dc-hz.aliyuncs.com") - - def test_endpoint_validation_no_scheme(self): - """Test endpoint validation rejects URLs without scheme""" - with pytest.raises(ValidationError, match="URL must start with https:// or http://"): - AliyunConfig(license_key="test_license", endpoint="invalid.tracing-analysis-dc-hz.aliyuncs.com") - - def test_license_key_required(self): - """Test that license_key is required and cannot be empty""" - with pytest.raises(ValidationError): - AliyunConfig(license_key="", endpoint="https://tracing-analysis-dc-hz.aliyuncs.com") - - def test_valid_endpoint_format_examples(self): - """Test valid endpoint format examples from comments""" - valid_endpoints = [ - # cms2.0 public endpoint - "https://proj-xtrace-123456-cn-heyuan.cn-heyuan.log.aliyuncs.com/apm/trace/opentelemetry", - # cms2.0 intranet endpoint - "https://proj-xtrace-123456-cn-heyuan.cn-heyuan-intranet.log.aliyuncs.com/apm/trace/opentelemetry", - # xtrace public endpoint - "http://tracing-cn-heyuan.arms.aliyuncs.com", - # xtrace intranet endpoint - "http://tracing-cn-heyuan-internal.arms.aliyuncs.com", - ] - - for endpoint in valid_endpoints: - config = AliyunConfig(license_key="test_license", endpoint=endpoint) - assert config.endpoint == endpoint - - class TestConfigIntegration: - """Integration tests for configuration classes""" + """Cross-provider configuration sanity checks""" def test_all_configs_can_be_instantiated(self): """Test that all config classes can be instantiated with valid data""" @@ -388,7 +42,6 @@ class TestConfigIntegration: def test_url_normalization_consistency(self): """Test that URL normalization works consistently across configs""" - # Test that paths are removed from endpoints arize_config = ArizeConfig(endpoint="https://arize.com/api/v1/test") phoenix_with_path_config = PhoenixConfig(endpoint="https://app.phoenix.arize.com/s/dify-integration") phoenix_without_path_config = PhoenixConfig(endpoint="https://app.phoenix.arize.com") diff --git a/api/uv.lock b/api/uv.lock index 77ba905a67..6d507a2d15 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -11,6 +11,14 @@ resolution-markers = [ [manifest] members = [ "dify-api", + "dify-trace-aliyun", + "dify-trace-arize-phoenix", + "dify-trace-langfuse", + "dify-trace-langsmith", + "dify-trace-mlflow", + "dify-trace-opik", + "dify-trace-tencent", + "dify-trace-weave", "dify-vdb-alibabacloud-mysql", "dify-vdb-analyticdb", "dify-vdb-baidu", @@ -1285,7 +1293,6 @@ version = "1.13.3" source = { virtual = "." } dependencies = [ { name = "aliyun-log-python-sdk" }, - { name = "arize-phoenix-otel" }, { name = "azure-identity" }, { name = "bleach" }, { name = "boto3" }, @@ -1308,9 +1315,6 @@ dependencies = [ { name = "httpx", extra = ["socks"] }, { name = "httpx-sse" }, { name = "json-repair" }, - { name = "langfuse" }, - { name = "langsmith" }, - { name = "mlflow-skinny" }, { name = "opentelemetry-distro" }, { name = "opentelemetry-instrumentation-celery" }, { name = "opentelemetry-instrumentation-flask" }, @@ -1318,7 +1322,6 @@ dependencies = [ { name = "opentelemetry-instrumentation-redis" }, { name = "opentelemetry-instrumentation-sqlalchemy" }, { name = "opentelemetry-propagator-b3" }, - { name = "opik" }, { name = "psycogreen" }, { name = "psycopg2-binary" }, { name = "python-socketio" }, @@ -1327,7 +1330,6 @@ dependencies = [ { name = "resend" }, { name = "sendgrid" }, { name = "sseclient-py" }, - { name = "weave" }, ] [package.dev-dependencies] @@ -1410,6 +1412,40 @@ tools = [ { name = "cloudscraper" }, { name = "nltk" }, ] +trace-aliyun = [ + { name = "dify-trace-aliyun" }, +] +trace-all = [ + { name = "dify-trace-aliyun" }, + { name = "dify-trace-arize-phoenix" }, + { name = "dify-trace-langfuse" }, + { name = "dify-trace-langsmith" }, + { name = "dify-trace-mlflow" }, + { name = "dify-trace-opik" }, + { name = "dify-trace-tencent" }, + { name = "dify-trace-weave" }, +] +trace-arize-phoenix = [ + { name = "dify-trace-arize-phoenix" }, +] +trace-langfuse = [ + { name = "dify-trace-langfuse" }, +] +trace-langsmith = [ + { name = "dify-trace-langsmith" }, +] +trace-mlflow = [ + { name = "dify-trace-mlflow" }, +] +trace-opik = [ + { name = "dify-trace-opik" }, +] +trace-tencent = [ + { name = "dify-trace-tencent" }, +] +trace-weave = [ + { name = "dify-trace-weave" }, +] vdb-alibabacloud-mysql = [ { name = "dify-vdb-alibabacloud-mysql" }, ] @@ -1539,7 +1575,6 @@ vdb-xinference = [ [package.metadata] requires-dist = [ { name = "aliyun-log-python-sdk", specifier = ">=0.9.44,<1.0.0" }, - { name = "arize-phoenix-otel", specifier = "~=0.15.0" }, { name = "azure-identity", specifier = ">=1.25.3,<2.0.0" }, { name = "bleach", specifier = ">=6.3.0" }, { name = "boto3", specifier = ">=1.42.88" }, @@ -1562,9 +1597,6 @@ requires-dist = [ { name = "httpx", extras = ["socks"], specifier = ">=0.28.1,<1.0.0" }, { name = "httpx-sse", specifier = "~=0.4.0" }, { name = "json-repair", specifier = "~=0.59.2" }, - { name = "langfuse", specifier = ">=4.2.0,<5.0.0" }, - { name = "langsmith", specifier = ">=0.7.31,<1.0.0" }, - { name = "mlflow-skinny", specifier = ">=3.11.1,<4.0.0" }, { name = "opentelemetry-distro", specifier = ">=0.62b0,<1.0.0" }, { name = "opentelemetry-instrumentation-celery", specifier = ">=0.62b0,<1.0.0" }, { name = "opentelemetry-instrumentation-flask", specifier = ">=0.62b0,<1.0.0" }, @@ -1572,7 +1604,6 @@ requires-dist = [ { name = "opentelemetry-instrumentation-redis", specifier = ">=0.62b0,<1.0.0" }, { name = "opentelemetry-instrumentation-sqlalchemy", specifier = ">=0.62b0,<1.0.0" }, { name = "opentelemetry-propagator-b3", specifier = ">=1.41.0,<2.0.0" }, - { name = "opik", specifier = "~=1.11.2" }, { name = "psycogreen", specifier = ">=1.0.2" }, { name = "psycopg2-binary", specifier = ">=2.9.11" }, { name = "python-socketio", specifier = ">=5.13.0" }, @@ -1581,7 +1612,6 @@ requires-dist = [ { name = "resend", specifier = ">=2.27.0,<3.0.0" }, { name = "sendgrid", specifier = ">=6.12.5" }, { name = "sseclient-py", specifier = ">=1.8.0" }, - { name = "weave", specifier = ">=0.52.36,<1.0.0" }, ] [package.metadata.requires-dev] @@ -1664,6 +1694,24 @@ tools = [ { name = "cloudscraper", specifier = ">=1.2.71" }, { name = "nltk", specifier = ">=3.9.1" }, ] +trace-aliyun = [{ name = "dify-trace-aliyun", editable = "providers/trace/trace-aliyun" }] +trace-all = [ + { name = "dify-trace-aliyun", editable = "providers/trace/trace-aliyun" }, + { name = "dify-trace-arize-phoenix", editable = "providers/trace/trace-arize-phoenix" }, + { name = "dify-trace-langfuse", editable = "providers/trace/trace-langfuse" }, + { name = "dify-trace-langsmith", editable = "providers/trace/trace-langsmith" }, + { name = "dify-trace-mlflow", editable = "providers/trace/trace-mlflow" }, + { name = "dify-trace-opik", editable = "providers/trace/trace-opik" }, + { name = "dify-trace-tencent", editable = "providers/trace/trace-tencent" }, + { name = "dify-trace-weave", editable = "providers/trace/trace-weave" }, +] +trace-arize-phoenix = [{ name = "dify-trace-arize-phoenix", editable = "providers/trace/trace-arize-phoenix" }] +trace-langfuse = [{ name = "dify-trace-langfuse", editable = "providers/trace/trace-langfuse" }] +trace-langsmith = [{ name = "dify-trace-langsmith", editable = "providers/trace/trace-langsmith" }] +trace-mlflow = [{ name = "dify-trace-mlflow", editable = "providers/trace/trace-mlflow" }] +trace-opik = [{ name = "dify-trace-opik", editable = "providers/trace/trace-opik" }] +trace-tencent = [{ name = "dify-trace-tencent", editable = "providers/trace/trace-tencent" }] +trace-weave = [{ name = "dify-trace-weave", editable = "providers/trace/trace-weave" }] vdb-alibabacloud-mysql = [{ name = "dify-vdb-alibabacloud-mysql", editable = "providers/vdb/vdb-alibabacloud-mysql" }] vdb-all = [ { name = "dify-vdb-alibabacloud-mysql", editable = "providers/vdb/vdb-alibabacloud-mysql" }, @@ -1728,6 +1776,110 @@ vdb-vikingdb = [{ name = "dify-vdb-vikingdb", editable = "providers/vdb/vdb-viki vdb-weaviate = [{ name = "dify-vdb-weaviate", editable = "providers/vdb/vdb-weaviate" }] vdb-xinference = [{ name = "xinference-client", specifier = ">=2.4.0" }] +[[package]] +name = "dify-trace-aliyun" +version = "0.0.1" +source = { editable = "providers/trace/trace-aliyun" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-grpc" }, + { name = "opentelemetry-sdk" }, + { name = "opentelemetry-semantic-conventions" }, +] + +[package.metadata] +requires-dist = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-grpc" }, + { name = "opentelemetry-sdk" }, + { name = "opentelemetry-semantic-conventions" }, +] + +[[package]] +name = "dify-trace-arize-phoenix" +version = "0.0.1" +source = { editable = "providers/trace/trace-arize-phoenix" } +dependencies = [ + { name = "arize-phoenix-otel" }, +] + +[package.metadata] +requires-dist = [{ name = "arize-phoenix-otel", specifier = "~=0.15.0" }] + +[[package]] +name = "dify-trace-langfuse" +version = "0.0.1" +source = { editable = "providers/trace/trace-langfuse" } +dependencies = [ + { name = "langfuse" }, +] + +[package.metadata] +requires-dist = [{ name = "langfuse", specifier = ">=4.2.0,<5.0.0" }] + +[[package]] +name = "dify-trace-langsmith" +version = "0.0.1" +source = { editable = "providers/trace/trace-langsmith" } +dependencies = [ + { name = "langsmith" }, +] + +[package.metadata] +requires-dist = [{ name = "langsmith", specifier = "~=0.7.30" }] + +[[package]] +name = "dify-trace-mlflow" +version = "0.0.1" +source = { editable = "providers/trace/trace-mlflow" } +dependencies = [ + { name = "mlflow-skinny" }, +] + +[package.metadata] +requires-dist = [{ name = "mlflow-skinny", specifier = ">=3.11.1" }] + +[[package]] +name = "dify-trace-opik" +version = "0.0.1" +source = { editable = "providers/trace/trace-opik" } +dependencies = [ + { name = "opik" }, +] + +[package.metadata] +requires-dist = [{ name = "opik", specifier = "~=1.11.2" }] + +[[package]] +name = "dify-trace-tencent" +version = "0.0.1" +source = { editable = "providers/trace/trace-tencent" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-grpc" }, + { name = "opentelemetry-sdk" }, + { name = "opentelemetry-semantic-conventions" }, +] + +[package.metadata] +requires-dist = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-grpc" }, + { name = "opentelemetry-sdk" }, + { name = "opentelemetry-semantic-conventions" }, +] + +[[package]] +name = "dify-trace-weave" +version = "0.0.1" +source = { editable = "providers/trace/trace-weave" } +dependencies = [ + { name = "weave" }, +] + +[package.metadata] +requires-dist = [{ name = "weave", specifier = ">=0.52.36" }] + [[package]] name = "dify-vdb-alibabacloud-mysql" version = "0.0.1" diff --git a/dev/pytest/pytest_unit_tests.sh b/dev/pytest/pytest_unit_tests.sh index 962532de81..012c870c19 100755 --- a/dev/pytest/pytest_unit_tests.sh +++ b/dev/pytest/pytest_unit_tests.sh @@ -13,6 +13,7 @@ PYTEST_XDIST_ARGS="${PYTEST_XDIST_ARGS:--n auto}" pytest --timeout "${PYTEST_TIMEOUT}" ${PYTEST_XDIST_ARGS} \ api/tests/unit_tests \ api/providers/vdb/*/tests/unit_tests \ + api/providers/trace/*/tests/unit_tests \ --ignore=api/tests/unit_tests/controllers # Run controller tests sequentially to avoid import race conditions From e70e4fa41d74e48a28a1b5cfec9ab0bcaef83f86 Mon Sep 17 00:00:00 2001 From: jerryzai Date: Fri, 17 Apr 2026 04:12:31 -0400 Subject: [PATCH 10/24] chore(api): migrate file factory builders and account commands to use Session(db.engine) (#35236) Co-authored-by: Asuka Minato Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- api/commands/account.py | 17 +- api/factories/file_factory/builders.py | 186 +++++++++--------- .../factories/test_build_from_mapping.py | 44 ++++- 3 files changed, 141 insertions(+), 106 deletions(-) diff --git a/api/commands/account.py b/api/commands/account.py index 6a2a2e0428..761323a73d 100644 --- a/api/commands/account.py +++ b/api/commands/account.py @@ -2,6 +2,7 @@ import base64 import secrets import click +from sqlalchemy.orm import Session from constants.languages import languages from extensions.ext_database import db @@ -43,10 +44,11 @@ def reset_password(email, new_password, password_confirm): # encrypt password with salt password_hashed = hash_password(new_password, salt) base64_password_hashed = base64.b64encode(password_hashed).decode() - account = db.session.merge(account) - account.password = base64_password_hashed - account.password_salt = base64_salt - db.session.commit() + with Session(db.engine) as session: + account = session.merge(account) + account.password = base64_password_hashed + account.password_salt = base64_salt + session.commit() AccountService.reset_login_error_rate_limit(normalized_email) click.echo(click.style("Password reset successfully.", fg="green")) @@ -77,9 +79,10 @@ def reset_email(email, new_email, email_confirm): click.echo(click.style(f"Invalid email: {new_email}", fg="red")) return - account = db.session.merge(account) - account.email = normalized_new_email - db.session.commit() + with Session(db.engine) as session: + account = session.merge(account) + account.email = normalized_new_email + session.commit() click.echo(click.style("Email updated successfully.", fg="green")) diff --git a/api/factories/file_factory/builders.py b/api/factories/file_factory/builders.py index 288d37d265..ce1fa441c2 100644 --- a/api/factories/file_factory/builders.py +++ b/api/factories/file_factory/builders.py @@ -10,8 +10,8 @@ from typing import Any from sqlalchemy import select from core.app.file_access import FileAccessControllerProtocol +from core.db.session_factory import session_factory from core.workflow.file_reference import build_file_reference -from extensions.ext_database import db from graphon.file import File, FileTransferMethod, FileType, FileUploadConfig, helpers, standardize_file_type from models import ToolFile, UploadFile @@ -135,29 +135,30 @@ def _build_from_local_file( UploadFile.id == upload_file_id, UploadFile.tenant_id == tenant_id, ) - row = db.session.scalar(access_controller.apply_upload_file_filters(stmt)) - if row is None: - raise ValueError("Invalid upload file") + with session_factory.create_session() as session: + row = session.scalar(access_controller.apply_upload_file_filters(stmt)) + if row is None: + raise ValueError("Invalid upload file") - detected_file_type = standardize_file_type(extension="." + row.extension, mime_type=row.mime_type) - file_type = _resolve_file_type( - detected_file_type=detected_file_type, - specified_type=mapping.get("type", "custom"), - strict_type_validation=strict_type_validation, - ) + detected_file_type = standardize_file_type(extension="." + row.extension, mime_type=row.mime_type) + file_type = _resolve_file_type( + detected_file_type=detected_file_type, + specified_type=mapping.get("type", "custom"), + strict_type_validation=strict_type_validation, + ) - return File( - id=mapping.get("id"), - filename=row.name, - extension="." + row.extension, - mime_type=row.mime_type, - type=file_type, - transfer_method=transfer_method, - remote_url=row.source_url, - reference=build_file_reference(record_id=str(row.id)), - size=row.size, - storage_key=row.key, - ) + return File( + id=mapping.get("id"), + filename=row.name, + extension="." + row.extension, + mime_type=row.mime_type, + type=file_type, + transfer_method=transfer_method, + remote_url=row.source_url, + reference=build_file_reference(record_id=str(row.id)), + size=row.size, + storage_key=row.key, + ) def _build_from_remote_url( @@ -179,32 +180,33 @@ def _build_from_remote_url( UploadFile.id == upload_file_id, UploadFile.tenant_id == tenant_id, ) - upload_file = db.session.scalar(access_controller.apply_upload_file_filters(stmt)) - if upload_file is None: - raise ValueError("Invalid upload file") + with session_factory.create_session() as session: + upload_file = session.scalar(access_controller.apply_upload_file_filters(stmt)) + if upload_file is None: + raise ValueError("Invalid upload file") - detected_file_type = standardize_file_type( - extension="." + upload_file.extension, - mime_type=upload_file.mime_type, - ) - file_type = _resolve_file_type( - detected_file_type=detected_file_type, - specified_type=mapping.get("type"), - strict_type_validation=strict_type_validation, - ) + detected_file_type = standardize_file_type( + extension="." + upload_file.extension, + mime_type=upload_file.mime_type, + ) + file_type = _resolve_file_type( + detected_file_type=detected_file_type, + specified_type=mapping.get("type"), + strict_type_validation=strict_type_validation, + ) - return File( - id=mapping.get("id"), - filename=upload_file.name, - extension="." + upload_file.extension, - mime_type=upload_file.mime_type, - type=file_type, - transfer_method=transfer_method, - remote_url=helpers.get_signed_file_url(upload_file_id=str(upload_file_id)), - reference=build_file_reference(record_id=str(upload_file.id)), - size=upload_file.size, - storage_key=upload_file.key, - ) + return File( + id=mapping.get("id"), + filename=upload_file.name, + extension="." + upload_file.extension, + mime_type=upload_file.mime_type, + type=file_type, + transfer_method=transfer_method, + remote_url=helpers.get_signed_file_url(upload_file_id=str(upload_file_id)), + reference=build_file_reference(record_id=str(upload_file.id)), + size=upload_file.size, + storage_key=upload_file.key, + ) url = mapping.get("url") or mapping.get("remote_url") if not url: @@ -247,30 +249,31 @@ def _build_from_tool_file( ToolFile.id == tool_file_id, ToolFile.tenant_id == tenant_id, ) - tool_file = db.session.scalar(access_controller.apply_tool_file_filters(stmt)) - if tool_file is None: - raise ValueError(f"ToolFile {tool_file_id} not found") + with session_factory.create_session() as session: + tool_file = session.scalar(access_controller.apply_tool_file_filters(stmt)) + if tool_file is None: + raise ValueError(f"ToolFile {tool_file_id} not found") - extension = "." + tool_file.file_key.split(".")[-1] if "." in tool_file.file_key else ".bin" - detected_file_type = standardize_file_type(extension=extension, mime_type=tool_file.mimetype) - file_type = _resolve_file_type( - detected_file_type=detected_file_type, - specified_type=mapping.get("type"), - strict_type_validation=strict_type_validation, - ) + extension = "." + tool_file.file_key.split(".")[-1] if "." in tool_file.file_key else ".bin" + detected_file_type = standardize_file_type(extension=extension, mime_type=tool_file.mimetype) + file_type = _resolve_file_type( + detected_file_type=detected_file_type, + specified_type=mapping.get("type"), + strict_type_validation=strict_type_validation, + ) - return File( - id=mapping.get("id"), - filename=tool_file.name, - type=file_type, - transfer_method=transfer_method, - remote_url=tool_file.original_url, - reference=build_file_reference(record_id=str(tool_file.id)), - extension=extension, - mime_type=tool_file.mimetype, - size=tool_file.size, - storage_key=tool_file.file_key, - ) + return File( + id=mapping.get("id"), + filename=tool_file.name, + type=file_type, + transfer_method=transfer_method, + remote_url=tool_file.original_url, + reference=build_file_reference(record_id=str(tool_file.id)), + extension=extension, + mime_type=tool_file.mimetype, + size=tool_file.size, + storage_key=tool_file.file_key, + ) def _build_from_datasource_file( @@ -289,31 +292,32 @@ def _build_from_datasource_file( UploadFile.id == datasource_file_id, UploadFile.tenant_id == tenant_id, ) - datasource_file = db.session.scalar(access_controller.apply_upload_file_filters(stmt)) - if datasource_file is None: - raise ValueError(f"DatasourceFile {mapping.get('datasource_file_id')} not found") + with session_factory.create_session() as session: + datasource_file = session.scalar(access_controller.apply_upload_file_filters(stmt)) + if datasource_file is None: + raise ValueError(f"DatasourceFile {mapping.get('datasource_file_id')} not found") - extension = "." + datasource_file.key.split(".")[-1] if "." in datasource_file.key else ".bin" - detected_file_type = standardize_file_type(extension="." + extension, mime_type=datasource_file.mime_type) - file_type = _resolve_file_type( - detected_file_type=detected_file_type, - specified_type=mapping.get("type"), - strict_type_validation=strict_type_validation, - ) + extension = "." + datasource_file.key.split(".")[-1] if "." in datasource_file.key else ".bin" + detected_file_type = standardize_file_type(extension="." + extension, mime_type=datasource_file.mime_type) + file_type = _resolve_file_type( + detected_file_type=detected_file_type, + specified_type=mapping.get("type"), + strict_type_validation=strict_type_validation, + ) - return File( - id=mapping.get("datasource_file_id"), - filename=datasource_file.name, - type=file_type, - transfer_method=FileTransferMethod.TOOL_FILE, - remote_url=datasource_file.source_url, - reference=build_file_reference(record_id=str(datasource_file.id)), - extension=extension, - mime_type=datasource_file.mime_type, - size=datasource_file.size, - storage_key=datasource_file.key, - url=datasource_file.source_url, - ) + return File( + id=mapping.get("datasource_file_id"), + filename=datasource_file.name, + type=file_type, + transfer_method=FileTransferMethod.TOOL_FILE, + remote_url=datasource_file.source_url, + reference=build_file_reference(record_id=str(datasource_file.id)), + extension=extension, + mime_type=datasource_file.mime_type, + size=datasource_file.size, + storage_key=datasource_file.key, + url=datasource_file.source_url, + ) def _is_valid_mapping(mapping: Mapping[str, Any]) -> bool: diff --git a/api/tests/unit_tests/factories/test_build_from_mapping.py b/api/tests/unit_tests/factories/test_build_from_mapping.py index 511192001e..efafc8aa79 100644 --- a/api/tests/unit_tests/factories/test_build_from_mapping.py +++ b/api/tests/unit_tests/factories/test_build_from_mapping.py @@ -11,6 +11,21 @@ from factories.file_factory.builders import build_from_mapping as _build_from_ma from graphon.file import File, FileTransferMethod, FileType, FileUploadConfig from models import ToolFile, UploadFile + +def _make_session_ctx_mock(scalar_return=None): + """Return a mock usable as the ``session_factory.create_session()`` context manager. + + Patch ``factories.file_factory.builders.session_factory`` and set + ``mock_sf.create_session.return_value = `` to intercept DB calls + without requiring a live Flask app or database engine. + """ + session = MagicMock() + session.__enter__.return_value = session + session.__exit__.return_value = False + session.scalar.return_value = scalar_return + return session + + # Test Data TEST_TENANT_ID = "test_tenant_id" TEST_UPLOAD_FILE_ID = str(uuid.uuid4()) @@ -49,8 +64,11 @@ def mock_upload_file(): mock.source_url = TEST_REMOTE_URL mock.size = 1024 mock.key = "test_key" - with patch("factories.file_factory.builders.db.session.scalar", return_value=mock, autospec=True) as m: - yield m + session = _make_session_ctx_mock(scalar_return=mock) + with patch("factories.file_factory.builders.session_factory") as mock_sf: + mock_sf.create_session.return_value = session + # yield session.scalar so callers can inspect call_args and mutate return_value + yield session.scalar @pytest.fixture @@ -63,7 +81,9 @@ def mock_tool_file(): mock.mimetype = "application/pdf" mock.original_url = "http://example.com/tool.pdf" mock.size = 2048 - with patch("factories.file_factory.builders.db.session.scalar", return_value=mock, autospec=True): + session = _make_session_ctx_mock(scalar_return=mock) + with patch("factories.file_factory.builders.session_factory") as mock_sf: + mock_sf.create_session.return_value = session yield mock @@ -231,7 +251,9 @@ def test_build_from_remote_url_without_strict_validation(mock_http_head): def test_tool_file_not_found(): """Test ToolFile not found in database.""" - with patch("factories.file_factory.builders.db.session.scalar", return_value=None, autospec=True): + session = _make_session_ctx_mock(scalar_return=None) + with patch("factories.file_factory.builders.session_factory") as mock_sf: + mock_sf.create_session.return_value = session mapping = tool_file_mapping() with pytest.raises(ValueError, match=f"ToolFile {TEST_TOOL_FILE_ID} not found"): build_from_mapping(mapping=mapping, tenant_id=TEST_TENANT_ID) @@ -239,7 +261,9 @@ def test_tool_file_not_found(): def test_local_file_not_found(): """Test UploadFile not found in database.""" - with patch("factories.file_factory.builders.db.session.scalar", return_value=None, autospec=True): + session = _make_session_ctx_mock(scalar_return=None) + with patch("factories.file_factory.builders.session_factory") as mock_sf: + mock_sf.create_session.return_value = session mapping = local_file_mapping() with pytest.raises(ValueError, match="Invalid upload file"): build_from_mapping(mapping=mapping, tenant_id=TEST_TENANT_ID) @@ -311,7 +335,9 @@ def test_tenant_mismatch(): mock_file.key = "test_key" # Mock the database query to return None (no file found for this tenant) - with patch("factories.file_factory.builders.db.session.scalar", return_value=None, autospec=True): + session = _make_session_ctx_mock(scalar_return=None) + with patch("factories.file_factory.builders.session_factory") as mock_sf: + mock_sf.create_session.return_value = session mapping = local_file_mapping() with pytest.raises(ValueError, match="Invalid upload file"): build_from_mapping(mapping=mapping, tenant_id=TEST_TENANT_ID) @@ -350,11 +376,13 @@ def test_build_from_mapping_scopes_tool_file_to_end_user(): invoke_from=InvokeFrom.WEB_APP, ) - with patch("factories.file_factory.builders.db.session.scalar", return_value=tool_file, autospec=True) as scalar: + session = _make_session_ctx_mock(scalar_return=tool_file) + with patch("factories.file_factory.builders.session_factory") as mock_sf: + mock_sf.create_session.return_value = session with bind_file_access_scope(scope): build_from_mapping(mapping=tool_file_mapping(), tenant_id=TEST_TENANT_ID) - stmt = scalar.call_args.args[0] + stmt = session.scalar.call_args.args[0] whereclause = str(stmt.whereclause) assert "tool_files.user_id" in whereclause From 0020aa8f599bef78566f6b390359d36a89dd3824 Mon Sep 17 00:00:00 2001 From: YBoy <231405196+YB0y@users.noreply.github.com> Date: Fri, 17 Apr 2026 02:13:54 -0600 Subject: [PATCH 11/24] refactor(api): type pipeline template retrieval dicts with TypedDict (#34874) --- .../built_in/built_in_retrieval.py | 4 +- .../customized/customized_retrieval.py | 36 ++++++++++++++---- .../database/database_retrieval.py | 37 +++++++++++++++---- .../remote/remote_retrieval.py | 11 ++---- 4 files changed, 65 insertions(+), 23 deletions(-) diff --git a/api/services/rag_pipeline/pipeline_template/built_in/built_in_retrieval.py b/api/services/rag_pipeline/pipeline_template/built_in/built_in_retrieval.py index aa7456dcd3..8c9a81af87 100644 --- a/api/services/rag_pipeline/pipeline_template/built_in/built_in_retrieval.py +++ b/api/services/rag_pipeline/pipeline_template/built_in/built_in_retrieval.py @@ -50,7 +50,7 @@ class BuiltInPipelineTemplateRetrieval(PipelineTemplateRetrievalBase): :param language: language :return: """ - builtin_data: dict[str, dict[str, dict]] = cls._get_builtin_data() + builtin_data: dict[str, dict[str, dict[str, Any]]] = cls._get_builtin_data() return builtin_data.get("pipeline_templates", {}).get(language, {}) @classmethod @@ -60,5 +60,5 @@ class BuiltInPipelineTemplateRetrieval(PipelineTemplateRetrievalBase): :param template_id: Template ID :return: """ - builtin_data: dict[str, dict[str, dict]] = cls._get_builtin_data() + builtin_data: dict[str, dict[str, dict[str, Any]]] = cls._get_builtin_data() return builtin_data.get("pipeline_templates", {}).get(template_id) diff --git a/api/services/rag_pipeline/pipeline_template/customized/customized_retrieval.py b/api/services/rag_pipeline/pipeline_template/customized/customized_retrieval.py index 0ffbef8365..9d446f6d4b 100644 --- a/api/services/rag_pipeline/pipeline_template/customized/customized_retrieval.py +++ b/api/services/rag_pipeline/pipeline_template/customized/customized_retrieval.py @@ -1,4 +1,4 @@ -from typing import Any +from typing import Any, TypedDict import yaml from sqlalchemy import select @@ -10,6 +10,30 @@ from services.rag_pipeline.pipeline_template.pipeline_template_base import Pipel from services.rag_pipeline.pipeline_template.pipeline_template_type import PipelineTemplateType +class CustomizedTemplateItemDict(TypedDict): + id: str + name: str + description: str + icon: dict[str, Any] + position: int + chunk_structure: str + + +class CustomizedTemplatesResultDict(TypedDict): + pipeline_templates: list[CustomizedTemplateItemDict] + + +class CustomizedTemplateDetailDict(TypedDict): + id: str + name: str + icon_info: dict[str, Any] + description: str + chunk_structure: str + export_data: str + graph: dict[str, Any] + created_by: str + + class CustomizedPipelineTemplateRetrieval(PipelineTemplateRetrievalBase): """ Retrieval recommended app from database @@ -17,12 +41,10 @@ class CustomizedPipelineTemplateRetrieval(PipelineTemplateRetrievalBase): def get_pipeline_templates(self, language: str) -> dict[str, Any]: _, current_tenant_id = current_account_with_tenant() - result = self.fetch_pipeline_templates_from_customized(tenant_id=current_tenant_id, language=language) - return result + return self.fetch_pipeline_templates_from_customized(tenant_id=current_tenant_id, language=language) def get_pipeline_template_detail(self, template_id: str) -> dict[str, Any] | None: - result = self.fetch_pipeline_template_detail_from_db(template_id) - return result + return self.fetch_pipeline_template_detail_from_db(template_id) def get_type(self) -> str: return PipelineTemplateType.CUSTOMIZED @@ -40,9 +62,9 @@ class CustomizedPipelineTemplateRetrieval(PipelineTemplateRetrievalBase): .where(PipelineCustomizedTemplate.tenant_id == tenant_id, PipelineCustomizedTemplate.language == language) .order_by(PipelineCustomizedTemplate.position.asc(), PipelineCustomizedTemplate.created_at.desc()) ).all() - recommended_pipelines_results = [] + recommended_pipelines_results: list[CustomizedTemplateItemDict] = [] for pipeline_customized_template in pipeline_customized_templates: - recommended_pipeline_result = { + recommended_pipeline_result: CustomizedTemplateItemDict = { "id": pipeline_customized_template.id, "name": pipeline_customized_template.name, "description": pipeline_customized_template.description, diff --git a/api/services/rag_pipeline/pipeline_template/database/database_retrieval.py b/api/services/rag_pipeline/pipeline_template/database/database_retrieval.py index 073eed221c..2964537c35 100644 --- a/api/services/rag_pipeline/pipeline_template/database/database_retrieval.py +++ b/api/services/rag_pipeline/pipeline_template/database/database_retrieval.py @@ -1,4 +1,4 @@ -from typing import Any +from typing import Any, TypedDict import yaml from sqlalchemy import select @@ -9,18 +9,41 @@ from services.rag_pipeline.pipeline_template.pipeline_template_base import Pipel from services.rag_pipeline.pipeline_template.pipeline_template_type import PipelineTemplateType +class PipelineTemplateItemDict(TypedDict): + id: str + name: str + description: str + icon: dict[str, Any] + copyright: str + privacy_policy: str + position: int + chunk_structure: str + + +class PipelineTemplatesResultDict(TypedDict): + pipeline_templates: list[PipelineTemplateItemDict] + + +class PipelineTemplateDetailDict(TypedDict): + id: str + name: str + icon_info: dict[str, Any] + description: str + chunk_structure: str + export_data: str + graph: dict[str, Any] + + class DatabasePipelineTemplateRetrieval(PipelineTemplateRetrievalBase): """ Retrieval pipeline template from database """ def get_pipeline_templates(self, language: str) -> dict[str, Any]: - result = self.fetch_pipeline_templates_from_db(language) - return result + return self.fetch_pipeline_templates_from_db(language) def get_pipeline_template_detail(self, template_id: str) -> dict[str, Any] | None: - result = self.fetch_pipeline_template_detail_from_db(template_id) - return result + return self.fetch_pipeline_template_detail_from_db(template_id) def get_type(self) -> str: return PipelineTemplateType.DATABASE @@ -39,9 +62,9 @@ class DatabasePipelineTemplateRetrieval(PipelineTemplateRetrievalBase): ).all() ) - recommended_pipelines_results = [] + recommended_pipelines_results: list[PipelineTemplateItemDict] = [] for pipeline_built_in_template in pipeline_built_in_templates: - recommended_pipeline_result = { + recommended_pipeline_result: PipelineTemplateItemDict = { "id": pipeline_built_in_template.id, "name": pipeline_built_in_template.name, "description": pipeline_built_in_template.description, diff --git a/api/services/rag_pipeline/pipeline_template/remote/remote_retrieval.py b/api/services/rag_pipeline/pipeline_template/remote/remote_retrieval.py index d5ef745bec..9565ac46cc 100644 --- a/api/services/rag_pipeline/pipeline_template/remote/remote_retrieval.py +++ b/api/services/rag_pipeline/pipeline_template/remote/remote_retrieval.py @@ -17,21 +17,18 @@ class RemotePipelineTemplateRetrieval(PipelineTemplateRetrievalBase): """ def get_pipeline_template_detail(self, template_id: str) -> dict[str, Any] | None: - result: dict[str, Any] | None try: - result = self.fetch_pipeline_template_detail_from_dify_official(template_id) + return self.fetch_pipeline_template_detail_from_dify_official(template_id) except Exception as e: logger.warning("fetch recommended app detail from dify official failed: %r, switch to database.", e) - result = DatabasePipelineTemplateRetrieval.fetch_pipeline_template_detail_from_db(template_id) - return result + return DatabasePipelineTemplateRetrieval.fetch_pipeline_template_detail_from_db(template_id) def get_pipeline_templates(self, language: str) -> dict[str, Any]: try: - result = self.fetch_pipeline_templates_from_dify_official(language) + return self.fetch_pipeline_templates_from_dify_official(language) except Exception as e: logger.warning("fetch pipeline templates from dify official failed: %r, switch to database.", e) - result = DatabasePipelineTemplateRetrieval.fetch_pipeline_templates_from_db(language) - return result + return DatabasePipelineTemplateRetrieval.fetch_pipeline_templates_from_db(language) def get_type(self) -> str: return PipelineTemplateType.REMOTE From bd25240123cf046dd36adc819d6a2e8cf5eed012 Mon Sep 17 00:00:00 2001 From: hyl64 <78853927+hyl64@users.noreply.github.com> Date: Fri, 17 Apr 2026 16:21:32 +0800 Subject: [PATCH 12/24] fix: raise chat settings select dropdown above dialog (#35357) Co-authored-by: Stephen Zhou --- .../inputs-form/__tests__/content.spec.tsx | 13 +++++++++++++ .../chat/chat-with-history/inputs-form/content.tsx | 2 +- .../inputs-form/__tests__/content.spec.tsx | 11 +++++++++++ .../chat/embedded-chatbot/inputs-form/content.tsx | 2 +- 4 files changed, 26 insertions(+), 2 deletions(-) diff --git a/web/app/components/base/chat/chat-with-history/inputs-form/__tests__/content.spec.tsx b/web/app/components/base/chat/chat-with-history/inputs-form/__tests__/content.spec.tsx index c1a0f3e294..6081024490 100644 --- a/web/app/components/base/chat/chat-with-history/inputs-form/__tests__/content.spec.tsx +++ b/web/app/components/base/chat/chat-with-history/inputs-form/__tests__/content.spec.tsx @@ -248,6 +248,19 @@ describe('InputsFormContent', () => { expect(mockSetCurrentConversationInputs).toHaveBeenCalledWith(expect.objectContaining({ sel: 'A' })) }) + it('renders select dropdown above the settings dialog layer', async () => { + const user = userEvent.setup() + const context = createMockContext({ + inputsForms: [{ variable: 'sel', type: InputVarType.select, label: 'Sel', options: ['A', 'B'], default: 'B' }], + currentConversationInputs: {}, + }) + + renderWithContext(, context) + await user.click(screen.getByText('B')) + + expect(screen.getByText('A').closest('.z-\\[60\\]')).not.toBeNull() + }) + it('handles select input with existing value (value not in options -> shows placeholder)', () => { const context = createMockContext({ inputsForms: [{ variable: 'sel', type: InputVarType.select, label: 'Sel', options: ['A'], default: undefined }], diff --git a/web/app/components/base/chat/chat-with-history/inputs-form/content.tsx b/web/app/components/base/chat/chat-with-history/inputs-form/content.tsx index 127cf2c252..4baa46744d 100644 --- a/web/app/components/base/chat/chat-with-history/inputs-form/content.tsx +++ b/web/app/components/base/chat/chat-with-history/inputs-form/content.tsx @@ -86,7 +86,7 @@ const InputsFormContent = ({ showTip }: Props) => { )} {form.type === InputVarType.select && ( ({ value: option, name: option }))} onSelect={item => handleFormChange(form.variable, item.value as string)} diff --git a/web/app/components/base/chat/embedded-chatbot/inputs-form/__tests__/content.spec.tsx b/web/app/components/base/chat/embedded-chatbot/inputs-form/__tests__/content.spec.tsx index 526fca2061..f682752645 100644 --- a/web/app/components/base/chat/embedded-chatbot/inputs-form/__tests__/content.spec.tsx +++ b/web/app/components/base/chat/embedded-chatbot/inputs-form/__tests__/content.spec.tsx @@ -200,6 +200,17 @@ describe('InputsFormContent', () => { expect(mockContextValue.handleNewConversationInputsChange).toHaveBeenCalled() }) + it('should render select dropdown above the settings dialog layer', async () => { + render() + const selectTrigger = screen.getAllByText(/Select Label/i).find(el => el.tagName === 'SPAN') + if (!selectTrigger) + throw new Error('Select trigger not found') + + await user.click(selectTrigger) + + expect(screen.getByText('Option 1').closest('.z-\\[60\\]')).not.toBeNull() + }) + it('should handle single file upload change', async () => { render() const uploadButtons = screen.getAllByText('Upload') diff --git a/web/app/components/base/chat/embedded-chatbot/inputs-form/content.tsx b/web/app/components/base/chat/embedded-chatbot/inputs-form/content.tsx index 2ce40480c3..733e3b1101 100644 --- a/web/app/components/base/chat/embedded-chatbot/inputs-form/content.tsx +++ b/web/app/components/base/chat/embedded-chatbot/inputs-form/content.tsx @@ -86,7 +86,7 @@ const InputsFormContent = ({ showTip }: Props) => { )} {form.type === InputVarType.select && ( ({ value: option, name: option }))} onSelect={item => handleFormChange(form.variable, item.value as string)} From f56ce9d3b1ad33b31a445be8b497d45918643d8e Mon Sep 17 00:00:00 2001 From: hyl64 <78853927+hyl64@users.noreply.github.com> Date: Fri, 17 Apr 2026 16:34:04 +0800 Subject: [PATCH 13/24] fix: guard chat file preview rendering when mime type is missing (#35355) Co-authored-by: Stephen Zhou --- .../__tests__/file-item.spec.tsx | 15 +++++++++++++++ .../file-uploader-in-chat-input/file-item.tsx | 7 ++++--- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/web/app/components/base/file-uploader/file-uploader-in-chat-input/__tests__/file-item.spec.tsx b/web/app/components/base/file-uploader/file-uploader-in-chat-input/__tests__/file-item.spec.tsx index c03f009cee..00dd7d9971 100644 --- a/web/app/components/base/file-uploader/file-uploader-in-chat-input/__tests__/file-item.spec.tsx +++ b/web/app/components/base/file-uploader/file-uploader-in-chat-input/__tests__/file-item.spec.tsx @@ -222,6 +222,21 @@ describe('FileItem (chat-input)', () => { expect(document.querySelector('audio')).not.toBeInTheDocument() }) + it('should not throw when file type is missing', () => { + expect(() => { + render( + , + ) + }).not.toThrow() + }) + it('should close video preview', () => { render(
{ - type.split('/')[0] === 'audio' && canPreview && previewUrl && ( + typeCategory === 'audio' && canPreview && previewUrl && ( { setPreviewUrl('') }} /> ) } From dfcc0f8863b58d87ae579450b65509a54f7eb49d Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Fri, 17 Apr 2026 16:46:11 +0800 Subject: [PATCH 14/24] refactor(dify-ui): finish primitive migration from web/base/ui to @langgenius/dify-ui (#35349) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- .github/workflows/web-tests.yml | 31 +++ codecov.yml | 9 + eslint-suppressions.json | 5 - packages/dify-ui/.gitignore | 3 + packages/dify-ui/.storybook/main.ts | 27 +++ packages/dify-ui/.storybook/preview.tsx | 31 +++ packages/dify-ui/.storybook/storybook.css | 19 ++ packages/dify-ui/AGENTS.md | 11 +- packages/dify-ui/package.json | 93 ++++++++- .../alert-dialog/__tests__/index.spec.tsx | 0 .../src}/alert-dialog/index.stories.tsx | 2 +- .../dify-ui/src}/alert-dialog/index.tsx | 6 +- .../src}/avatar/__tests__/index.spec.tsx | 0 .../dify-ui/src}/avatar/index.stories.tsx | 2 +- .../dify-ui/src}/avatar/index.tsx | 2 +- .../src}/button/__tests__/index.spec.tsx | 0 .../dify-ui/src}/button/index.stories.tsx | 2 +- .../dify-ui/src}/button/index.tsx | 2 +- .../context-menu/__tests__/index.spec.tsx | 0 .../src}/context-menu/index.stories.tsx | 2 +- .../dify-ui/src}/context-menu/index.tsx | 13 +- .../src}/dialog/__tests__/index.spec.tsx | 0 .../dify-ui/src}/dialog/index.stories.tsx | 2 +- .../dify-ui/src}/dialog/index.tsx | 2 +- .../dropdown-menu/__tests__/index.spec.tsx | 0 .../src}/dropdown-menu/index.stories.tsx | 2 +- .../dify-ui/src}/dropdown-menu/index.tsx | 12 +- .../number-field/__tests__/index.spec.tsx | 0 .../src}/number-field/index.stories.tsx | 4 +- .../dify-ui/src}/number-field/index.tsx | 2 +- .../dify-ui/src}/overlay-shared.ts | 0 .../ui => packages/dify-ui/src}/placement.ts | 0 .../src}/popover/__tests__/index.spec.tsx | 0 .../dify-ui/src}/popover/index.stories.tsx | 6 +- .../dify-ui/src}/popover/index.tsx | 8 +- .../src}/scroll-area/__tests__/index.spec.tsx | 0 .../src}/scroll-area/index.stories.tsx | 18 +- .../dify-ui/src}/scroll-area/index.tsx | 2 +- .../src}/select/__tests__/index.spec.tsx | 0 .../dify-ui/src}/select/index.stories.tsx | 2 +- .../dify-ui/src}/select/index.tsx | 11 +- .../src}/slider/__tests__/index.spec.tsx | 0 .../dify-ui/src}/slider/index.stories.tsx | 2 +- .../dify-ui/src}/slider/index.tsx | 2 +- .../src}/switch/__tests__/index.spec.tsx | 3 +- .../dify-ui/src}/switch/index.stories.tsx | 14 +- .../dify-ui/src}/switch/index.tsx | 85 ++++++--- .../src}/toast/__tests__/index.spec.tsx | 0 .../dify-ui/src}/toast/index.stories.tsx | 2 +- .../dify-ui/src}/toast/index.tsx | 2 +- .../src}/tooltip/__tests__/index.spec.tsx | 0 .../dify-ui/src}/tooltip/index.stories.tsx | 4 +- .../dify-ui/src}/tooltip/index.tsx | 8 +- packages/dify-ui/tailwind.config.ts | 23 +++ packages/dify-ui/tests/setup.ts | 44 +++++ packages/dify-ui/tsconfig.json | 12 +- packages/dify-ui/vite.config.ts | 27 +++ packages/tsconfig/nextjs.json | 2 +- packages/tsconfig/package.json | 2 +- packages/tsconfig/{web.json => react.json} | 0 pnpm-lock.yaml | 179 ++++++++++++++++++ pnpm-workspace.yaml | 1 + web/.storybook/preview.tsx | 2 +- web/__tests__/app/app-publisher-flow.test.tsx | 2 +- .../apps/app-card-operations-flow.test.tsx | 2 +- .../billing/cloud-plan-payment-flow.test.tsx | 2 +- .../billing/self-hosted-plan-flow.test.tsx | 2 +- .../datasets/create-dataset-flow.test.tsx | 2 +- .../datasets/dataset-settings-flow.test.tsx | 4 +- .../explore/sidebar-lifecycle-flow.test.tsx | 4 +- .../plugins/plugin-install-flow.test.ts | 2 +- .../dsl-export-import-flow.test.ts | 2 +- .../tools/tool-provider-detail-flow.test.tsx | 2 +- .../[appId]/overview/card-view.tsx | 2 +- .../[appId]/overview/tracing/config-popup.tsx | 2 +- .../[appId]/overview/tracing/panel.tsx | 2 +- .../tracing/provider-config-modal.tsx | 22 +-- .../(humanInputLayout)/form/[token]/form.tsx | 4 +- .../webapp-reset-password/check-code/page.tsx | 4 +- .../webapp-reset-password/page.tsx | 4 +- .../set-password/page.tsx | 4 +- .../webapp-signin/check-code/page.tsx | 4 +- .../components/external-member-sso-auth.tsx | 2 +- .../components/mail-and-code-auth.tsx | 4 +- .../components/mail-and-password-auth.tsx | 4 +- .../webapp-signin/components/sso-auth.tsx | 4 +- .../account-page/AvatarWithEdit.tsx | 10 +- .../account-page/email-change-modal.tsx | 6 +- .../(commonLayout)/account-page/index.tsx | 6 +- web/app/account/(commonLayout)/avatar.tsx | 2 +- .../delete-account/components/check-email.tsx | 2 +- .../delete-account/components/feed-back.tsx | 4 +- .../components/verify-email.tsx | 2 +- web/app/account/(commonLayout)/header.tsx | 2 +- web/app/account/oauth/authorize/page.tsx | 6 +- web/app/activate/activateForm.tsx | 2 +- .../__tests__/app-sidebar-dropdown.spec.tsx | 2 +- .../dataset-sidebar-dropdown.spec.tsx | 2 +- .../__tests__/app-info-detail-panel.spec.tsx | 2 +- .../__tests__/app-operations.spec.tsx | 4 +- .../__tests__/use-app-info-actions.spec.ts | 2 +- .../app-info/app-info-detail-panel.tsx | 2 +- .../app-sidebar/app-info/app-info-modals.tsx | 10 +- .../app-sidebar/app-info/app-operations.tsx | 10 +- .../app-info/use-app-info-actions.ts | 2 +- .../app-sidebar/app-sidebar-dropdown.tsx | 10 +- .../__tests__/dropdown-callbacks.spec.tsx | 2 +- .../app-sidebar/dataset-info/dropdown.tsx | 30 +-- .../app-sidebar/dataset-sidebar-dropdown.tsx | 10 +- .../components/app-sidebar/toggle-button.tsx | 2 +- .../app/annotation/__tests__/index.spec.tsx | 2 +- .../__tests__/index.spec.tsx | 2 +- .../annotation/add-annotation-modal/index.tsx | 4 +- .../app/annotation/batch-action.tsx | 14 +- .../__tests__/csv-uploader.spec.tsx | 2 +- .../__tests__/index.spec.tsx | 2 +- .../csv-uploader.tsx | 4 +- .../batch-add-annotation-modal/index.tsx | 4 +- .../index.tsx | 6 +- .../__tests__/index.spec.tsx | 2 +- .../edit-annotation-modal/edit-item/index.tsx | 2 +- .../edit-annotation-modal/index.tsx | 14 +- .../app/annotation/header-opts/index.tsx | 12 +- web/app/components/app/annotation/index.tsx | 4 +- .../remove-annotation-confirm-modal/index.tsx | 6 +- .../view-annotation-modal/index.tsx | 16 +- .../__tests__/access-control.spec.tsx | 2 +- .../__tests__/index.spec.tsx | 2 +- .../add-member-or-group-pop.tsx | 6 +- .../app/app-access-control/index.tsx | 4 +- .../specific-groups-or-members.tsx | 2 +- .../app-publisher/__tests__/index.spec.tsx | 2 +- .../publish-with-multiple-model.spec.tsx | 2 +- .../__tests__/version-info-modal.spec.tsx | 4 +- .../app/app-publisher/features-wrapper.tsx | 16 +- .../components/app/app-publisher/index.tsx | 6 +- .../publish-with-multiple-model.tsx | 10 +- .../components/app/app-publisher/sections.tsx | 12 +- .../app/app-publisher/version-info-modal.tsx | 4 +- .../warning-mask/cannot-query-dataset.tsx | 2 +- .../base/warning-mask/formatting-changed.tsx | 2 +- .../__tests__/advanced-prompt-input.spec.tsx | 2 +- .../__tests__/simple-prompt-input.spec.tsx | 2 +- .../config-prompt/advanced-prompt-input.tsx | 14 +- .../config-prompt/confirm-add-var/index.tsx | 2 +- .../conversation-history/edit-modal.tsx | 2 +- .../app/configuration/config-prompt/index.tsx | 2 +- .../config-prompt/simple-prompt-input.tsx | 12 +- .../config-var/__tests__/index.spec.tsx | 2 +- .../__tests__/form-fields.spec.tsx | 4 +- .../__tests__/index-logic.spec.tsx | 2 +- .../config-modal/__tests__/index.spec.tsx | 2 +- .../config-var/config-modal/form-fields.tsx | 12 +- .../config-var/config-modal/index.tsx | 2 +- .../app/configuration/config-var/index.tsx | 22 +-- .../configuration/config-var/modal-foot.tsx | 2 +- .../app/configuration/config-vision/index.tsx | 2 +- .../config-vision/param-config.tsx | 4 +- .../config/agent-setting-button.tsx | 2 +- .../agent-setting/__tests__/index.spec.tsx | 2 +- .../config/agent/agent-setting/index.tsx | 4 +- .../config/agent/agent-tools/index.tsx | 4 +- .../agent-tools/setting-built-in-tool.tsx | 2 +- .../__tests__/get-automatic-res.spec.tsx | 2 +- .../automatic/__tests__/result.spec.tsx | 4 +- .../config/automatic/automatic-btn.tsx | 2 +- .../config/automatic/get-automatic-res.tsx | 22 +-- .../configuration/config/automatic/result.tsx | 4 +- .../__tests__/get-code-generator-res.spec.tsx | 2 +- .../code-generator/get-code-generator-res.tsx | 22 +-- .../app/configuration/config/config-audio.tsx | 4 +- .../configuration/config/config-document.tsx | 4 +- .../app/configuration/configuration-view.tsx | 20 +- .../configuration/ctrl-btn-group/index.tsx | 2 +- .../__tests__/config-content.spec.tsx | 2 +- .../params-config/__tests__/index.spec.tsx | 2 +- .../params-config/config-content.tsx | 4 +- .../dataset-config/params-config/index.tsx | 4 +- .../params-config/weighted-score.tsx | 2 +- .../dataset-config/select-dataset/index.tsx | 2 +- .../settings-modal/__tests__/index.spec.tsx | 2 +- .../dataset-config/settings-modal/index.tsx | 4 +- .../debug/__tests__/index.spec.tsx | 2 +- .../__tests__/chat-item.spec.tsx | 2 +- .../debug-with-multiple-model/chat-item.tsx | 2 +- .../debug-with-multiple-model/debug-item.tsx | 8 +- .../debug/debug-with-single-model/index.tsx | 2 +- .../app/configuration/debug/index.tsx | 14 +- .../__tests__/use-configuration-utils.spec.ts | 2 +- .../hooks/use-configuration-utils.ts | 2 +- .../prompt-value-panel/index.tsx | 2 +- .../external-data-tool-modal.spec.tsx | 2 +- .../tools/external-data-tool-modal.tsx | 8 +- .../app/create-app-dialog/app-card/index.tsx | 2 +- .../app-list/__tests__/index.spec.tsx | 2 +- .../app/create-app-dialog/app-list/index.tsx | 2 +- .../create-app-modal/__tests__/index.spec.tsx | 2 +- .../components/app/create-app-modal/index.tsx | 6 +- .../__tests__/index.spec.tsx | 2 +- .../__tests__/uploader.spec.tsx | 4 +- .../dsl-confirm-modal.tsx | 2 +- .../app/create-from-dsl-modal/index.tsx | 4 +- .../app/create-from-dsl-modal/uploader.tsx | 2 +- .../duplicate-modal/__tests__/index.spec.tsx | 2 +- .../components/app/duplicate-modal/index.tsx | 4 +- .../components/app/in-site-message/index.tsx | 2 +- .../app/log/__tests__/model-info.spec.tsx | 2 +- web/app/components/app/log/list.tsx | 2 +- web/app/components/app/log/model-info.tsx | 2 +- .../overview/__tests__/trigger-card.spec.tsx | 4 +- .../app/overview/apikey-info-panel/index.tsx | 2 +- .../app/overview/app-card-sections.tsx | 14 +- web/app/components/app/overview/app-card.tsx | 2 +- .../app/overview/customize/index.tsx | 2 +- .../settings/__tests__/index.spec.tsx | 2 +- .../app/overview/settings/index.tsx | 6 +- .../components/app/overview/trigger-card.tsx | 2 +- .../switch-app-modal/__tests__/index.spec.tsx | 2 +- .../components/app/switch-app-modal/index.tsx | 22 +-- .../item/__tests__/action-groups.spec.tsx | 2 +- .../item/__tests__/index.spec.tsx | 2 +- .../app/text-generate/item/action-groups.tsx | 2 +- .../app/text-generate/item/index.tsx | 2 +- .../saved-items/__tests__/index.spec.tsx | 4 +- .../app/text-generate/saved-items/index.tsx | 2 +- .../saved-items/no-data/index.tsx | 2 +- .../components/app/type-selector/index.tsx | 10 +- .../apps/__tests__/app-card.spec.tsx | 16 +- web/app/components/apps/app-card.tsx | 24 +-- .../agent-log-modal/__tests__/detail.spec.tsx | 2 +- .../agent-log-modal/__tests__/index.spec.tsx | 2 +- .../base/agent-log-modal/detail.tsx | 2 +- .../base/agent-log-modal/index.stories.tsx | 2 +- .../components/base/app-icon-picker/index.tsx | 2 +- .../base/audio-btn/__tests__/audio.spec.ts | 2 +- web/app/components/base/audio-btn/audio.ts | 2 +- .../base/audio-gallery/AudioPlayer.tsx | 2 +- .../__tests__/AudioPlayer.spec.tsx | 2 +- .../base/block-input/__tests__/index.spec.tsx | 2 +- web/app/components/base/block-input/index.tsx | 2 +- .../__tests__/hooks.spec.tsx | 2 +- .../chat/chat-with-history/chat-wrapper.tsx | 2 +- .../chat-with-history/header-in-mobile.tsx | 14 +- .../chat/chat-with-history/header/index.tsx | 18 +- .../header/mobile-operation-dropdown.tsx | 8 +- .../chat-with-history/header/operation.tsx | 10 +- .../base/chat/chat-with-history/hooks.tsx | 2 +- .../chat-with-history/inputs-form/index.tsx | 2 +- .../chat/chat-with-history/sidebar/index.tsx | 20 +- .../chat-with-history/sidebar/operation.tsx | 12 +- .../sidebar/rename-modal.tsx | 2 +- .../check-input-forms-hooks.spec.tsx | 2 +- .../base/chat/chat/__tests__/hooks.spec.tsx | 2 +- .../chat/chat/__tests__/question.spec.tsx | 2 +- .../chat/answer/__tests__/operation.spec.tsx | 2 +- .../human-input-content/human-input-form.tsx | 4 +- .../base/chat/chat/answer/operation.tsx | 2 +- .../chat-input-area/__tests__/index.spec.tsx | 2 +- .../base/chat/chat/chat-input-area/index.tsx | 2 +- .../chat/chat/chat-input-area/operation.tsx | 2 +- .../base/chat/chat/check-input-forms-hooks.ts | 2 +- web/app/components/base/chat/chat/hooks.ts | 2 +- web/app/components/base/chat/chat/index.tsx | 2 +- .../components/base/chat/chat/question.tsx | 4 +- .../components/base/chat/chat/try-to-ask.tsx | 2 +- .../embedded-chatbot/__tests__/hooks.spec.tsx | 2 +- .../chat/embedded-chatbot/chat-wrapper.tsx | 2 +- .../base/chat/embedded-chatbot/hooks.tsx | 2 +- .../inputs-form/__tests__/content.spec.tsx | 2 +- .../embedded-chatbot/inputs-form/index.tsx | 2 +- .../components/base/checkbox-list/index.tsx | 2 +- .../date-picker/footer.tsx | 2 +- .../time-picker/footer.tsx | 2 +- .../year-and-month-picker/footer.tsx | 2 +- web/app/components/base/drawer/index.tsx | 2 +- .../components/base/emoji-picker/index.tsx | 2 +- .../components/base/error-boundary/index.tsx | 2 +- .../__tests__/annotation-ctrl-button.spec.tsx | 2 +- .../__tests__/config-param-modal.spec.tsx | 2 +- .../annotation-ctrl-button.tsx | 2 +- .../annotation-reply/config-param-modal.tsx | 4 +- .../annotation-reply/index.tsx | 2 +- .../annotation-reply/score-slider/index.tsx | 2 +- .../conversation-opener/index.tsx | 2 +- .../conversation-opener/modal.tsx | 2 +- .../new-feature-panel/feature-bar.tsx | 2 +- .../new-feature-panel/feature-card.tsx | 2 +- .../new-feature-panel/file-upload/index.tsx | 2 +- .../file-upload/setting-content.tsx | 2 +- .../new-feature-panel/image-upload/index.tsx | 2 +- .../moderation-setting-modal.spec.tsx | 2 +- .../new-feature-panel/moderation/index.tsx | 2 +- .../moderation/moderation-content.tsx | 2 +- .../moderation/moderation-setting-modal.tsx | 4 +- .../text-to-speech/index.tsx | 2 +- .../text-to-speech/param-config-content.tsx | 2 +- .../file-uploader/__tests__/hooks.spec.ts | 2 +- .../file-from-link-or-local/index.tsx | 2 +- .../index.stories.tsx | 2 +- .../file-uploader-in-attachment/index.tsx | 2 +- .../file-image-item.tsx | 2 +- .../file-uploader-in-chat-input/file-item.tsx | 2 +- .../index.stories.tsx | 2 +- .../components/base/file-uploader/hooks.ts | 2 +- .../form/components/field/number-input.tsx | 8 +- .../base/form/components/form/actions.tsx | 2 +- .../__tests__/use-check-validated.spec.ts | 2 +- .../base/form/hooks/use-check-validated.ts | 2 +- .../components/base/form/index.stories.tsx | 2 +- .../image-uploader/__tests__/hooks.spec.ts | 2 +- .../__tests__/image-preview.spec.tsx | 2 +- .../components/base/image-uploader/hooks.ts | 2 +- .../base/image-uploader/image-link-input.tsx | 2 +- .../base/image-uploader/image-preview.tsx | 2 +- .../base/inline-delete-confirm/index.tsx | 2 +- .../base/markdown-blocks/button.tsx | 2 +- .../components/base/markdown-blocks/form.tsx | 6 +- .../components/base/modal-like-wrap/index.tsx | 2 +- web/app/components/base/modal/index.tsx | 2 +- web/app/components/base/modal/modal.tsx | 6 +- .../base/notion-connector/index.tsx | 2 +- web/app/components/base/pagination/index.tsx | 2 +- web/app/components/base/param-item/index.tsx | 8 +- .../base/portal-to-follow-elem/index.tsx | 10 +- .../plugins/hitl-input-block/input-field.tsx | 2 +- .../workflow-variable-block/component.tsx | 2 +- web/app/components/base/select/index.tsx | 2 +- web/app/components/base/switch/skeleton.tsx | 41 ---- .../base/tag-input/__tests__/index.spec.tsx | 2 +- .../base/tag-input/__tests__/interop.spec.tsx | 4 +- web/app/components/base/tag-input/index.tsx | 2 +- .../tag-management/__tests__/index.spec.tsx | 2 +- .../tag-management/__tests__/panel.spec.tsx | 2 +- .../__tests__/selector.spec.tsx | 2 +- .../__tests__/tag-item-editor.spec.tsx | 2 +- .../components/base/tag-management/filter.tsx | 10 +- .../base/tag-management/index.stories.tsx | 2 +- .../components/base/tag-management/index.tsx | 2 +- .../components/base/tag-management/panel.tsx | 2 +- .../base/tag-management/selector.tsx | 6 +- .../base/tag-management/tag-item-editor.tsx | 14 +- .../base/tag-management/tag-remove-modal.tsx | 2 +- .../text-generation/__tests__/hooks.spec.ts | 2 +- .../components/base/text-generation/hooks.ts | 2 +- web/app/components/base/tooltip/index.tsx | 2 +- .../base/user-avatar-list/index.tsx | 4 +- .../billing/apps-full-in-dialog/index.tsx | 2 +- .../billing/plan-upgrade-modal/index.tsx | 2 +- web/app/components/billing/plan/index.tsx | 2 +- .../billing/pricing/__tests__/dialog.spec.tsx | 2 +- .../billing/pricing/__tests__/header.spec.tsx | 2 +- web/app/components/billing/pricing/header.tsx | 4 +- web/app/components/billing/pricing/index.tsx | 8 +- .../plan-switcher/plan-range-switcher.tsx | 2 +- .../cloud-plan-item/__tests__/index.spec.tsx | 2 +- .../pricing/plans/cloud-plan-item/index.tsx | 2 +- .../__tests__/index.spec.tsx | 2 +- .../plans/self-hosted-plan-item/index.tsx | 2 +- .../components/billing/upgrade-btn/index.tsx | 2 +- .../custom-page/__tests__/index.spec.tsx | 2 +- .../components/chat-preview-card.tsx | 2 +- .../components/workflow-preview-card.tsx | 2 +- .../__tests__/use-web-app-brand.spec.tsx | 2 +- .../hooks/use-web-app-brand.ts | 2 +- .../custom/custom-web-app-brand/index.tsx | 4 +- .../__tests__/auto-disabled-document.spec.tsx | 4 +- .../auto-disabled-document.tsx | 2 +- .../datasets/common/image-previewer/index.tsx | 2 +- .../hooks/__tests__/use-upload.spec.tsx | 4 +- .../common/image-uploader/hooks/use-upload.ts | 2 +- .../image-uploader-in-chunk/image-item.tsx | 2 +- .../image-item.tsx | 2 +- .../__tests__/index.spec.tsx | 6 +- .../common/retrieval-param-config/index.tsx | 4 +- .../__tests__/index.spec.tsx | 2 +- .../__tests__/uploader.spec.tsx | 2 +- .../dsl-confirm-modal.tsx | 2 +- .../hooks/__tests__/use-dsl-import.spec.tsx | 2 +- .../hooks/use-dsl-import.ts | 2 +- .../create-from-dsl-modal/index.tsx | 2 +- .../create-from-dsl-modal/uploader.tsx | 2 +- .../datasets/create-from-pipeline/header.tsx | 2 +- .../list/__tests__/create-card.spec.tsx | 4 +- .../create-from-pipeline/list/create-card.tsx | 2 +- .../__tests__/edit-pipeline-info.spec.tsx | 4 +- .../template-card/__tests__/index.spec.tsx | 4 +- .../list/template-card/actions.tsx | 8 +- .../list/template-card/details/index.tsx | 2 +- .../list/template-card/edit-pipeline-info.tsx | 4 +- .../list/template-card/index.tsx | 14 +- .../create/embedding-process/index.tsx | 2 +- .../__tests__/index.spec.tsx | 2 +- .../empty-dataset-creation-modal/index.tsx | 4 +- .../hooks/__tests__/use-file-upload.spec.tsx | 2 +- .../file-uploader/hooks/use-file-upload.ts | 2 +- .../step-one/components/next-step-button.tsx | 2 +- .../components/general-chunking-options.tsx | 2 +- .../components/indexing-mode-section.tsx | 2 +- .../create/step-two/components/inputs.tsx | 12 +- .../components/parent-child-options.tsx | 2 +- .../step-two/components/step-two-footer.tsx | 2 +- .../__tests__/use-document-creation.spec.ts | 2 +- .../step-two/hooks/use-document-creation.ts | 2 +- .../datasets/create/step-two/index.tsx | 2 +- .../language-select/__tests__/index.spec.tsx | 4 +- .../create/step-two/language-select/index.tsx | 2 +- .../create/stop-embedding-modal/index.tsx | 2 +- .../website/base/crawled-result-item.tsx | 2 +- .../datasets/create/website/base/header.tsx | 2 +- .../create/website/base/url-input.tsx | 2 +- .../create/website/firecrawl/index.tsx | 2 +- .../website/jina-reader/base/url-input.tsx | 2 +- .../create/website/jina-reader/index.tsx | 2 +- .../datasets/create/website/no-data.tsx | 2 +- .../create/website/watercrawl/index.tsx | 2 +- .../components/__tests__/operations.spec.tsx | 2 +- .../__tests__/rename-modal.spec.tsx | 2 +- .../__tests__/use-document-actions.spec.ts | 2 +- .../hooks/use-document-actions.ts | 2 +- .../documents/components/documents-header.tsx | 2 +- .../documents/components/empty-element.tsx | 2 +- .../documents/components/operations.tsx | 24 +-- .../documents/components/rename-modal.tsx | 4 +- .../create-from-pipeline/actions/index.tsx | 2 +- .../base/__tests__/header.spec.tsx | 2 +- .../data-source/base/header.tsx | 2 +- .../__tests__/use-local-file-upload.spec.tsx | 2 +- .../online-documents/__tests__/index.spec.tsx | 4 +- .../data-source/online-documents/index.tsx | 2 +- .../online-drive/__tests__/index.spec.tsx | 4 +- .../online-drive/connect/index.tsx | 2 +- .../header/breadcrumbs/dropdown/index.tsx | 6 +- .../file-list/list/empty-search-result.tsx | 2 +- .../data-source/online-drive/header.tsx | 2 +- .../data-source/online-drive/index.tsx | 2 +- .../__tests__/crawled-result-item.spec.tsx | 2 +- .../base/crawled-result-item.tsx | 2 +- .../base/options/__tests__/index.spec.tsx | 4 +- .../website-crawl/base/options/index.tsx | 4 +- .../create-from-pipeline/left-header.tsx | 2 +- .../online-document-preview.spec.tsx | 4 +- .../preview/chunk-preview.tsx | 2 +- .../preview/online-document-preview.tsx | 2 +- .../__tests__/components.spec.tsx | 4 +- .../process-documents/__tests__/form.spec.tsx | 4 +- .../__tests__/header.spec.tsx | 2 +- .../process-documents/actions.tsx | 2 +- .../process-documents/form.tsx | 2 +- .../process-documents/header.tsx | 2 +- .../processing/embedding-process/index.tsx | 2 +- .../documents/detail/__tests__/index.spec.tsx | 2 +- .../detail/__tests__/new-segment.spec.tsx | 2 +- .../__tests__/csv-uploader.spec.tsx | 2 +- .../detail/batch-modal/csv-uploader.tsx | 4 +- .../documents/detail/batch-modal/index.tsx | 2 +- .../detail/completed/__tests__/index.spec.tsx | 2 +- .../__tests__/new-child-segment.spec.tsx | 2 +- .../completed/common/action-buttons.tsx | 2 +- .../detail/completed/common/batch-action.tsx | 18 +- .../completed/common/regeneration-modal.tsx | 2 +- .../__tests__/use-child-segment-data.spec.ts | 2 +- .../__tests__/use-segment-list-data.spec.ts | 2 +- .../completed/hooks/use-child-segment-data.ts | 2 +- .../completed/hooks/use-segment-list-data.ts | 2 +- .../detail/completed/new-child-segment.tsx | 2 +- .../detail/completed/segment-card/index.tsx | 20 +- .../detail/embedding/__tests__/index.spec.tsx | 2 +- .../documents/detail/embedding/index.tsx | 2 +- .../datasets/documents/detail/index.tsx | 2 +- .../detail/metadata/__tests__/index.spec.tsx | 2 +- .../metadata/components/doc-type-selector.tsx | 2 +- .../__tests__/use-metadata-state.spec.ts | 2 +- .../metadata/hooks/use-metadata-state.ts | 2 +- .../documents/detail/metadata/index.tsx | 2 +- .../datasets/documents/detail/new-segment.tsx | 2 +- .../documents/detail/segment-add/index.tsx | 10 +- .../pipeline-settings/left-header.tsx | 2 +- .../process-documents/actions.tsx | 2 +- .../status-item/__tests__/index.spec.tsx | 2 +- .../datasets/documents/status-item/index.tsx | 4 +- .../__tests__/index.spec.tsx | 2 +- .../external-api/external-api-modal/index.tsx | 18 +- .../external-api/external-api-panel/index.tsx | 2 +- .../external-knowledge-api-card/index.tsx | 18 +- .../connector/__tests__/index.spec.tsx | 4 +- .../connector/index.tsx | 2 +- .../create/ExternalApiSelection.tsx | 2 +- .../external-knowledge-base/create/index.tsx | 2 +- .../datasets/extra-info/api-access/card.tsx | 2 +- .../datasets/extra-info/api-access/index.tsx | 2 +- .../datasets/extra-info/service-api/card.tsx | 2 +- .../datasets/extra-info/service-api/index.tsx | 2 +- .../modify-external-retrieval-modal.spec.tsx | 2 +- .../__tests__/modify-retrieval-modal.spec.tsx | 4 +- .../components/query-input/index.tsx | 2 +- .../modify-external-retrieval-modal.tsx | 2 +- .../hit-testing/modify-retrieval-modal.tsx | 4 +- .../__tests__/operations.spec.tsx | 2 +- .../components/dataset-card-modals.tsx | 6 +- .../components/operations-dropdown.tsx | 4 +- .../__tests__/use-dataset-card-state.spec.ts | 10 +- .../hooks/use-dataset-card-state.ts | 2 +- .../datasets/list/dataset-card/operations.tsx | 6 +- web/app/components/datasets/list/index.tsx | 4 +- .../datasets/metadata/add-metadata-button.tsx | 2 +- .../__tests__/modal.spec.tsx | 2 +- .../edit-metadata-batch/input-combined.tsx | 6 +- .../metadata/edit-metadata-batch/modal.tsx | 4 +- .../use-batch-edit-document-metadata.spec.ts | 2 +- .../use-edit-dataset-metadata.spec.ts | 2 +- .../__tests__/use-metadata-document.spec.ts | 2 +- .../hooks/use-batch-edit-document-metadata.ts | 2 +- .../hooks/use-edit-dataset-metadata.ts | 2 +- .../metadata/hooks/use-metadata-document.ts | 2 +- .../__tests__/create-metadata-modal.spec.tsx | 2 +- .../dataset-metadata-drawer.spec.tsx | 2 +- .../__tests__/select-metadata-modal.spec.tsx | 2 +- .../create-metadata-modal.tsx | 2 +- .../dataset-metadata-drawer.tsx | 28 +-- .../select-metadata-modal.tsx | 4 +- .../metadata/metadata-document/index.tsx | 2 +- .../metadata/metadata-document/no-data.tsx | 2 +- .../rename-modal/__tests__/index.spec.tsx | 2 +- .../datasets/rename-modal/index.tsx | 4 +- .../settings/form/__tests__/index.spec.tsx | 4 +- .../hooks/__tests__/use-form-state.spec.ts | 10 +- .../settings/form/hooks/use-form-state.ts | 2 +- .../datasets/settings/form/index.tsx | 2 +- .../settings/index-method/keyword-number.tsx | 12 +- .../settings/permission-selector/index.tsx | 2 +- .../settings/summary-index-setting.tsx | 2 +- .../develop/secret-key/secret-key-button.tsx | 2 +- .../secret-key/secret-key-generate.tsx | 2 +- .../develop/secret-key/secret-key-modal.tsx | 20 +- web/app/components/explore/app-card/index.tsx | 2 +- web/app/components/explore/app-list/index.tsx | 2 +- .../explore/create-app-modal/index.tsx | 6 +- .../item-operation/__tests__/index.spec.tsx | 2 +- .../explore/item-operation/index.tsx | 12 +- .../explore/sidebar/__tests__/index.spec.tsx | 4 +- web/app/components/explore/sidebar/index.tsx | 18 +- .../explore/try-app/app-info/index.tsx | 2 +- web/app/components/explore/try-app/index.tsx | 2 +- web/app/components/goto-anything/index.tsx | 2 +- .../components/header/account-about/index.tsx | 2 +- .../__tests__/compliance.spec.tsx | 4 +- .../__tests__/support.spec.tsx | 4 +- .../header/account-dropdown/compliance.tsx | 8 +- .../header/account-dropdown/index.tsx | 4 +- .../header/account-dropdown/support.tsx | 2 +- .../__tests__/index.spec.tsx | 6 +- .../workplace-selector/index.tsx | 6 +- .../__tests__/menu-dialog.dialog.spec.tsx | 2 +- .../__tests__/modal.spec.tsx | 2 +- .../api-based-extension-page/index.tsx | 2 +- .../api-based-extension-page/item.tsx | 16 +- .../api-based-extension-page/modal.tsx | 4 +- .../data-source-page-new/card.tsx | 14 +- .../data-source-page-new/configure.tsx | 2 +- .../data-source-page-new/item.tsx | 2 +- .../data-source-page-new/operator.tsx | 14 +- .../header/account-setting/index.tsx | 4 +- .../language-page/__tests__/index.spec.tsx | 2 +- .../account-setting/language-page/index.tsx | 2 +- .../__tests__/dialog.spec.tsx | 2 +- .../__tests__/index.spec.tsx | 2 +- .../edit-workspace-modal/index.tsx | 6 +- .../account-setting/members-page/index.tsx | 4 +- .../members-page/invite-button.tsx | 2 +- .../invite-modal/__tests__/index.spec.tsx | 4 +- .../members-page/invite-modal/index.tsx | 6 +- .../invite-modal/role-selector.tsx | 6 +- .../members-page/invited-modal/index.tsx | 6 +- .../invited-modal/invitation-link.tsx | 2 +- .../members-page/operation/index.tsx | 8 +- .../__tests__/index.spec.tsx | 2 +- .../transfer-ownership-modal/index.tsx | 4 +- .../member-selector.tsx | 2 +- .../header/account-setting/menu-dialog.tsx | 2 +- .../model-auth/add-custom-model.tsx | 6 +- .../model-auth/authorized/credential-item.tsx | 2 +- .../model-auth/authorized/index.tsx | 18 +- .../model-auth/config-model.tsx | 2 +- .../model-auth/config-provider.tsx | 6 +- .../hooks/__tests__/use-auth.spec.tsx | 4 +- .../model-auth/hooks/use-auth.ts | 2 +- .../manage-custom-model-credentials.tsx | 6 +- .../switch-credential-in-load-balancing.tsx | 2 +- .../model-modal/__tests__/dialog.spec.tsx | 4 +- .../model-provider-page/model-modal/index.tsx | 28 +-- .../__tests__/parameter-item.select.spec.tsx | 4 +- .../__tests__/parameter-item.spec.tsx | 2 +- .../configuration-button.tsx | 2 +- .../model-parameter-modal/index.tsx | 10 +- .../model-parameter-modal/parameter-item.tsx | 8 +- .../presets-parameter.tsx | 12 +- .../model-parameter-modal/trigger.tsx | 2 +- .../model-selector/__tests__/popover.spec.tsx | 2 +- .../__tests__/popup-item.spec.tsx | 2 +- .../model-selector/index.tsx | 4 +- .../model-selector/model-selector-trigger.tsx | 2 +- .../model-selector/popup-item.tsx | 10 +- .../model-selector/popup.tsx | 2 +- .../__tests__/credential-panel.spec.tsx | 2 +- .../model-load-balancing-modal.spec.tsx | 4 +- .../use-change-provider-priority.spec.ts | 2 +- .../__tests__/dialog.spec.tsx | 2 +- .../use-activate-credential.spec.tsx | 2 +- .../model-auth-dropdown/api-key-section.tsx | 2 +- .../model-auth-dropdown/dropdown-content.tsx | 6 +- .../model-auth-dropdown/index.tsx | 8 +- .../usage-priority-section.tsx | 2 +- .../use-activate-credential.ts | 2 +- .../provider-added-card/model-list-item.tsx | 2 +- .../model-load-balancing-configs.tsx | 2 +- .../model-load-balancing-modal.tsx | 16 +- .../provider-added-card/priority-selector.tsx | 2 +- .../provider-card-actions.tsx | 4 +- .../provider-added-card/quota-panel.tsx | 2 +- .../use-change-provider-priority.ts | 2 +- .../__tests__/index.spec.tsx | 4 +- .../system-model-selector/index.tsx | 12 +- .../plugin-page/SerpapiPlugin.tsx | 2 +- .../__tests__/SerpapiPlugin.spec.tsx | 2 +- .../plugin-page/__tests__/index.spec.tsx | 4 +- .../install-plugin/__tests__/hooks.spec.ts | 2 +- .../plugins/install-plugin/base/installed.tsx | 2 +- .../plugins/install-plugin/hooks.ts | 2 +- .../install-bundle/steps/install.tsx | 2 +- .../install-bundle/steps/installed.tsx | 2 +- .../__tests__/index.spec.tsx | 2 +- .../install-from-github/index.tsx | 2 +- .../install-from-github/steps/loaded.tsx | 2 +- .../steps/selectPackage.tsx | 2 +- .../install-from-github/steps/setURL.tsx | 2 +- .../steps/install.tsx | 2 +- .../steps/uploading.tsx | 2 +- .../steps/install.tsx | 2 +- .../plugins/marketplace/list/card-wrapper.tsx | 2 +- .../sort-dropdown/__tests__/index.spec.tsx | 2 +- .../marketplace/sort-dropdown/index.tsx | 4 +- .../__tests__/api-key-modal.spec.tsx | 2 +- .../__tests__/authorize-components.spec.tsx | 2 +- .../__tests__/oauth-client-settings.spec.tsx | 2 +- .../authorize/add-api-key-button.tsx | 4 +- .../authorize/add-oauth-button.tsx | 4 +- .../plugin-auth/authorize/api-key-modal.tsx | 2 +- .../authorize/oauth-client-settings.tsx | 4 +- .../authorized-in-data-source-node.tsx | 2 +- .../plugin-auth/authorized-in-node.tsx | 2 +- .../authorized/__tests__/index.spec.tsx | 2 +- .../plugins/plugin-auth/authorized/index.tsx | 20 +- .../plugins/plugin-auth/authorized/item.tsx | 2 +- .../__tests__/use-plugin-auth-action.spec.ts | 2 +- .../hooks/use-plugin-auth-action.ts | 2 +- .../plugin-auth/plugin-auth-in-agent.tsx | 2 +- .../plugin-auth-in-datasource-node.tsx | 2 +- .../__tests__/detail-header.spec.tsx | 2 +- .../__tests__/endpoint-card.spec.tsx | 2 +- .../__tests__/endpoint-modal.spec.tsx | 2 +- .../__tests__/operation-dropdown.spec.tsx | 2 +- .../datasource-action-list.tsx | 2 +- .../detail-header/__tests__/index.spec.tsx | 4 +- .../components/header-modals.tsx | 4 +- .../__tests__/use-plugin-operations.spec.ts | 2 +- .../hooks/use-plugin-operations.ts | 2 +- .../detail-header/index.tsx | 4 +- .../plugin-detail-panel/endpoint-card.tsx | 20 +- .../plugin-detail-panel/endpoint-list.tsx | 2 +- .../plugin-detail-panel/endpoint-modal.tsx | 4 +- .../model-selector/__tests__/index.spec.tsx | 2 +- .../__tests__/tts-params-panel.spec.tsx | 4 +- .../model-selector/index.tsx | 8 +- .../model-selector/tts-params-panel.tsx | 2 +- .../operation-dropdown.tsx | 8 +- .../__tests__/delete-confirm.spec.tsx | 4 +- .../__tests__/log-viewer.spec.tsx | 2 +- .../__tests__/selector-entry.spec.tsx | 2 +- .../__tests__/selector-view.spec.tsx | 2 +- .../__tests__/subscription-card.spec.tsx | 2 +- .../create/__tests__/common-modal.spec.tsx | 2 +- .../create/__tests__/index.spec.tsx | 4 +- .../create/__tests__/oauth-client.spec.tsx | 2 +- .../use-common-modal-state.helpers.spec.ts | 2 +- .../__tests__/use-common-modal-state.spec.ts | 2 +- .../__tests__/use-oauth-client-state.spec.ts | 2 +- .../hooks/use-common-modal-state.helpers.ts | 2 +- .../create/hooks/use-common-modal-state.ts | 2 +- .../create/hooks/use-oauth-client-state.ts | 2 +- .../subscription-list/create/index.tsx | 4 +- .../subscription-list/create/oauth-client.tsx | 4 +- .../subscription-list/delete-confirm.tsx | 10 +- .../edit/__tests__/apikey-edit-modal.spec.tsx | 2 +- .../edit/__tests__/index.spec.tsx | 2 +- .../edit/__tests__/manual-edit-modal.spec.tsx | 2 +- .../edit/__tests__/oauth-edit-modal.spec.tsx | 2 +- .../edit/apikey-edit-modal.tsx | 2 +- .../edit/manual-edit-modal.tsx | 2 +- .../edit/oauth-edit-modal.tsx | 2 +- .../subscription-list/log-viewer.tsx | 2 +- .../tool-selector/__tests__/index.spec.tsx | 2 +- .../__tests__/reasoning-config-form.spec.tsx | 4 +- .../__tests__/tool-credentials-form.spec.tsx | 2 +- .../components/reasoning-config-form.tsx | 2 +- .../components/tool-credentials-form.tsx | 4 +- .../tool-selector/components/tool-item.tsx | 4 +- .../plugin-item/__tests__/action.spec.tsx | 2 +- .../components/plugins/plugin-item/action.tsx | 14 +- .../plugins/plugin-mutation-model/index.tsx | 2 +- .../plugin-page/__tests__/debug-info.spec.tsx | 2 +- .../install-plugin-dropdown.spec.tsx | 4 +- .../__tests__/use-reference-setting.spec.ts | 4 +- .../plugins/plugin-page/debug-info.tsx | 2 +- .../plugins/plugin-page/empty/index.tsx | 2 +- .../components/plugins/plugin-page/index.tsx | 2 +- .../plugin-page/install-plugin-dropdown.tsx | 14 +- .../components/error-plugin-item.tsx | 2 +- .../components/plugin-task-list.tsx | 2 +- .../plugin-page/plugin-tasks/index.tsx | 10 +- .../plugins/plugin-page/plugins-panel.tsx | 2 +- .../plugin-page/use-reference-setting.ts | 2 +- web/app/components/plugins/provider-card.tsx | 2 +- .../__tests__/plugins-picker.spec.tsx | 2 +- .../__tests__/strategy-picker.spec.tsx | 2 +- .../auto-update-setting/plugins-picker.tsx | 2 +- .../auto-update-setting/strategy-picker.tsx | 2 +- .../plugins/reference-setting-modal/index.tsx | 2 +- .../__tests__/from-market-place.spec.tsx | 6 +- .../update-plugin/__tests__/index.spec.tsx | 2 +- .../update-plugin/downgrade-warning.tsx | 2 +- .../update-plugin/from-market-place.tsx | 14 +- .../update-plugin/plugin-version-picker.tsx | 12 +- .../components/__tests__/conversion.spec.tsx | 4 +- ...blish-as-knowledge-pipeline-modal.spec.tsx | 2 +- .../__tests__/update-dsl-modal.spec.tsx | 4 +- .../rag-pipeline/components/conversion.tsx | 12 +- .../editor/form/__tests__/index.spec.tsx | 2 +- .../panel/input-field/editor/form/index.tsx | 4 +- .../field-list/__tests__/hooks.spec.ts | 2 +- .../field-list/__tests__/index.spec.tsx | 2 +- .../panel/input-field/field-list/hooks.ts | 2 +- .../components/panel/input-field/index.tsx | 2 +- .../test-run/preparation/actions/index.tsx | 2 +- .../__tests__/index.spec.tsx | 2 +- .../__tests__/options.spec.tsx | 2 +- .../document-processing/actions.tsx | 2 +- .../document-processing/options.tsx | 2 +- .../test-run/result/result-preview/index.tsx | 2 +- .../publish-as-knowledge-pipeline-modal.tsx | 2 +- .../__tests__/index.spec.tsx | 2 +- .../input-field-button.tsx | 2 +- .../publisher/__tests__/index.spec.tsx | 2 +- .../publisher/__tests__/popup.spec.tsx | 4 +- .../rag-pipeline-header/publisher/index.tsx | 2 +- .../rag-pipeline-header/publisher/popup.tsx | 24 +-- .../components/update-dsl-modal.tsx | 2 +- .../components/version-mismatch-modal.tsx | 2 +- .../hooks/__tests__/index.spec.ts | 2 +- .../hooks/__tests__/use-DSL.spec.ts | 2 +- .../__tests__/use-update-dsl-modal.spec.ts | 2 +- .../components/rag-pipeline/hooks/use-DSL.ts | 2 +- .../hooks/use-update-dsl-modal.ts | 2 +- .../use-text-generation-app-state.spec.ts | 2 +- .../hooks/use-text-generation-app-state.ts | 2 +- .../share/text-generation/index.tsx | 2 +- .../share/text-generation/menu-dropdown.tsx | 14 +- .../result/__tests__/index.spec.tsx | 2 +- .../share/text-generation/result/index.tsx | 4 +- .../share/text-generation/run-batch/index.tsx | 2 +- .../run-batch/res-download/index.tsx | 2 +- .../share/text-generation/run-once/index.tsx | 2 +- .../__tests__/get-schema.spec.tsx | 2 +- .../__tests__/index.spec.tsx | 2 +- .../config-credentials.tsx | 2 +- .../get-schema.tsx | 4 +- .../edit-custom-collection-modal/index.tsx | 4 +- .../edit-custom-collection-modal/test-api.tsx | 2 +- web/app/components/tools/labels/filter.tsx | 10 +- .../tools/mcp/__tests__/modal.spec.tsx | 2 +- .../__tests__/operation-dropdown.spec.tsx | 2 +- .../components/tools/mcp/detail/content.tsx | 20 +- .../tools/mcp/detail/operation-dropdown.tsx | 12 +- .../components/tools/mcp/headers-input.tsx | 2 +- .../tools/mcp/hooks/use-mcp-modal-form.ts | 2 +- .../components/tools/mcp/mcp-server-modal.tsx | 2 +- .../components/tools/mcp/mcp-service-card.tsx | 22 +-- web/app/components/tools/mcp/modal.tsx | 4 +- .../components/tools/mcp/provider-card.tsx | 12 +- .../mcp/sections/authentication-section.tsx | 2 +- .../__tests__/custom-create-card.spec.tsx | 2 +- .../tools/provider/__tests__/detail.spec.tsx | 2 +- .../tools/provider/custom-create-card.tsx | 2 +- web/app/components/tools/provider/detail.tsx | 22 +-- .../__tests__/config-credentials.spec.tsx | 2 +- .../setting/build-in/config-credentials.tsx | 4 +- .../__tests__/configure-button.spec.tsx | 2 +- .../workflow-tool/__tests__/index.spec.tsx | 2 +- .../tools/workflow-tool/configure-button.tsx | 2 +- .../workflow-tool/confirm-modal/index.tsx | 2 +- .../__tests__/use-configure-button.spec.ts | 2 +- .../hooks/use-configure-button.ts | 2 +- .../components/tools/workflow-tool/index.tsx | 4 +- .../__tests__/features-trigger.spec.tsx | 2 +- .../workflow-header/features-trigger.tsx | 4 +- .../workflow-onboarding-modal/index.tsx | 2 +- .../hooks/__tests__/use-DSL.spec.ts | 2 +- .../__tests__/use-workflow-run-utils.spec.ts | 2 +- .../components/workflow-app/hooks/use-DSL.ts | 2 +- .../hooks/use-workflow-run-utils.ts | 2 +- .../__tests__/update-dsl-modal.spec.tsx | 4 +- .../__tests__/workflow-edge-events.spec.tsx | 2 +- .../__tests__/tool-picker.spec.tsx | 2 +- .../block-selector/all-start-blocks.tsx | 2 +- .../workflow/block-selector/all-tools.tsx | 2 +- .../market-place-plugin/action.tsx | 14 +- .../workflow/block-selector/tool-picker.tsx | 2 +- .../workflow/comment/comment-input.spec.tsx | 2 +- .../workflow/comment/comment-input.tsx | 2 +- .../workflow/comment/mention-input.spec.tsx | 2 +- .../workflow/comment/mention-input.tsx | 4 +- .../workflow/comment/thread.spec.tsx | 6 +- .../components/workflow/comment/thread.tsx | 14 +- .../workflow/dsl-export-confirm-modal.tsx | 2 +- .../components/workflow/edge-contextmenu.tsx | 10 +- .../header/__tests__/header-layouts.spec.tsx | 2 +- .../header/__tests__/run-mode.spec.tsx | 2 +- .../__tests__/test-run-menu-helpers.spec.tsx | 2 +- .../header/__tests__/test-run-menu.spec.tsx | 2 +- .../workflow/header/chat-variable-button.tsx | 2 +- .../header/checklist/__tests__/index.spec.tsx | 2 +- .../checklist/__tests__/plugin-group.spec.tsx | 2 +- .../workflow/header/checklist/index.tsx | 12 +- .../header/checklist/plugin-group.tsx | 4 +- .../components/workflow/header/env-button.tsx | 2 +- .../header/global-variable-button.tsx | 2 +- .../workflow/header/header-in-restoring.tsx | 4 +- .../header/header-in-view-history.tsx | 2 +- .../workflow/header/online-users.tsx | 12 +- .../components/workflow/header/run-mode.tsx | 2 +- .../workflow/header/test-run-menu-helpers.tsx | 2 +- .../workflow/header/test-run-menu.tsx | 2 +- .../header/version-history-button.tsx | 2 +- .../hooks/__tests__/use-checklist.spec.ts | 2 +- .../__tests__/use-leader-restore.spec.ts | 2 +- .../workflow/hooks/use-checklist.ts | 2 +- .../workflow/hooks/use-leader-restore.ts | 2 +- web/app/components/workflow/index.tsx | 20 +- .../__tests__/file-support.spec.tsx | 2 +- .../nodes/_base/components/add-button.tsx | 2 +- .../nodes/_base/components/agent-strategy.tsx | 14 +- .../before-run-form/__tests__/index.spec.tsx | 4 +- .../components/before-run-form/index.tsx | 4 +- .../nodes/_base/components/config-vision.tsx | 2 +- .../error-handle-type-selector.tsx | 2 +- .../components/input-number-with-slider.tsx | 2 +- .../components/install-plugin-button.tsx | 2 +- .../layout/__tests__/field-title.spec.tsx | 2 +- .../_base/components/layout/field-title.tsx | 2 +- .../nodes/_base/components/memory-config.tsx | 4 +- .../next-step/__tests__/operator.spec.tsx | 4 +- .../nodes/_base/components/next-step/item.tsx | 2 +- .../_base/components/next-step/operator.tsx | 12 +- .../nodes/_base/components/node-control.tsx | 10 +- .../_base/components/panel-operator/index.tsx | 10 +- .../nodes/_base/components/prompt/editor.tsx | 4 +- .../components/remove-effect-var-confirm.tsx | 6 +- .../_base/components/retry/retry-on-panel.tsx | 4 +- .../__tests__/output-var-list.spec.tsx | 2 +- .../components/variable/output-var-list.tsx | 2 +- .../_base/components/variable/var-list.tsx | 2 +- .../variable/var-reference-picker.trigger.tsx | 2 +- .../variable-label/base/variable-label.tsx | 2 +- .../workflow-panel/last-run/no-data.tsx | 2 +- .../workflow-panel/last-run/use-last-run.ts | 2 +- .../nodes/_base/hooks/use-one-step-run.ts | 4 +- .../workflow/nodes/_base/node-sections.tsx | 2 +- .../__tests__/operation-selector.spec.tsx | 2 +- .../components/operation-selector.tsx | 6 +- .../components/workflow/nodes/code/panel.tsx | 2 +- .../nodes/data-source-empty/index.tsx | 2 +- .../nodes/data-source/before-run-form.tsx | 2 +- .../components/__tests__/curl-panel.spec.tsx | 4 +- .../http/components/authorization/index.tsx | 2 +- .../nodes/http/components/curl-panel.tsx | 4 +- .../key-value/key-value-edit/item.tsx | 10 +- .../components/workflow/nodes/http/panel.tsx | 2 +- .../human-input/__tests__/panel.spec.tsx | 4 +- .../__tests__/button-style-dropdown.spec.tsx | 2 +- .../__tests__/form-content-preview.spec.tsx | 2 +- .../components/__tests__/user-action.spec.tsx | 4 +- .../components/button-style-dropdown.tsx | 2 +- .../__tests__/email-configure-modal.spec.tsx | 2 +- .../delivery-method/email-configure-modal.tsx | 6 +- .../delivery-method/method-item.tsx | 4 +- .../recipient/__tests__/index.spec.tsx | 5 +- .../delivery-method/recipient/email-item.tsx | 2 +- .../delivery-method/recipient/index.tsx | 2 +- .../delivery-method/recipient/member-list.tsx | 2 +- .../recipient/member-selector.tsx | 2 +- .../delivery-method/test-email-sender.tsx | 2 +- .../delivery-method/upgrade-modal.tsx | 2 +- .../components/form-content-preview.tsx | 4 +- .../components/single-run-form.tsx | 6 +- .../human-input/components/user-action.tsx | 4 +- .../workflow/nodes/human-input/panel.tsx | 4 +- .../if-else/components/condition-add.tsx | 2 +- .../condition-list/condition-operator.tsx | 2 +- .../components/condition-number-input.tsx | 2 +- .../if-else/components/condition-wrap.tsx | 2 +- .../workflow/nodes/if-else/panel.tsx | 2 +- .../iteration/__tests__/integration.spec.tsx | 4 +- .../workflow/nodes/iteration/node.tsx | 2 +- .../workflow/nodes/iteration/panel.tsx | 4 +- .../components/chunk-structure/index.tsx | 2 +- .../components/chunk-structure/selector.tsx | 2 +- .../components/index-method.tsx | 2 +- .../search-method-option.tsx | 2 +- .../top-k-and-score-threshold.tsx | 10 +- .../components/metadata/add-condition.tsx | 2 +- .../condition-list/condition-operator.tsx | 2 +- .../condition-list/condition-value-method.tsx | 2 +- .../metadata-filter-selector.tsx | 2 +- .../components/metadata/metadata-trigger.tsx | 2 +- .../components/retrieval-config.tsx | 2 +- .../list-operator/__tests__/panel.spec.tsx | 5 +- .../__tests__/limit-config.spec.tsx | 4 +- .../list-operator/components/limit-config.tsx | 2 +- .../workflow/nodes/list-operator/panel.tsx | 2 +- .../__tests__/json-importer.spec.tsx | 2 +- .../json-importer.tsx | 4 +- .../json-schema-config.tsx | 4 +- .../generated-result.tsx | 2 +- .../json-schema-generator/index.tsx | 2 +- .../json-schema-generator/prompt-editor.tsx | 2 +- .../visual-editor/add-field.tsx | 2 +- .../edit-card/advanced-actions.tsx | 2 +- .../edit-card/required-switch.tsx | 2 +- .../visual-editor/hooks.ts | 2 +- .../llm/components/panel-memory-section.tsx | 2 +- .../llm/components/panel-output-section.tsx | 4 +- .../components/reasoning-format-config.tsx | 2 +- .../nodes/llm/components/structure-output.tsx | 2 +- .../components/workflow/nodes/llm/panel.tsx | 2 +- .../nodes/loop/__tests__/integration.spec.tsx | 2 +- .../nodes/loop/components/condition-add.tsx | 2 +- .../condition-list/condition-operator.tsx | 2 +- .../components/condition-number-input.tsx | 2 +- .../nodes/loop/components/condition-wrap.tsx | 2 +- .../loop/components/loop-variables/item.tsx | 2 +- .../__tests__/integration.spec.tsx | 4 +- .../__tests__/update.spec.tsx | 4 +- .../components/extract-parameter/update.tsx | 6 +- .../nodes/start/__tests__/use-config.spec.ts | 2 +- .../nodes/start/components/var-list.tsx | 2 +- .../workflow/nodes/start/use-config.ts | 2 +- .../nodes/tool/components/tool-form/item.tsx | 2 +- .../workflow/nodes/tool/hooks/use-config.ts | 2 +- .../components/trigger-form/item.tsx | 2 +- .../components/on-minute-selector.tsx | 2 +- .../__tests__/use-config.spec.tsx | 4 +- .../workflow/nodes/trigger-webhook/panel.tsx | 20 +- .../nodes/trigger-webhook/use-config.ts | 2 +- .../__tests__/integration.spec.tsx | 4 +- .../__tests__/var-group-item.spec.tsx | 4 +- .../components/add-variable/index.tsx | 2 +- .../components/var-group-item.tsx | 2 +- .../nodes/variable-assigner/panel.tsx | 2 +- .../__tests__/hooks.spec.tsx | 2 +- .../plugins/link-editor-plugin/component.tsx | 2 +- .../plugins/link-editor-plugin/hooks.ts | 2 +- .../toolbar/__tests__/operator.spec.tsx | 2 +- .../note-editor/toolbar/operator.tsx | 14 +- .../operator/__tests__/more-actions.spec.tsx | 2 +- .../workflow/operator/more-actions.tsx | 14 +- .../workflow/operator/zoom-in-out.tsx | 14 +- .../panel/__tests__/inputs-panel.spec.tsx | 2 +- .../panel/__tests__/workflow-preview.spec.tsx | 4 +- .../__tests__/object-value-item.spec.tsx | 2 +- .../__tests__/variable-modal.spec.tsx | 4 +- .../components/array-bool-list.tsx | 2 +- .../components/array-value-list.tsx | 2 +- .../components/object-value-item.tsx | 2 +- .../components/variable-modal-trigger.tsx | 4 +- .../components/variable-modal.sections.tsx | 2 +- .../components/variable-modal.tsx | 4 +- .../comments-panel/__tests__/index.spec.tsx | 4 +- .../workflow/panel/comments-panel/index.tsx | 2 +- .../debug-and-preview/__tests__/hooks.spec.ts | 2 +- .../__tests__/hooks/handle-resume.spec.ts | 2 +- .../__tests__/hooks/handle-send.spec.ts | 2 +- .../hooks/handle-stop-restart.spec.ts | 2 +- .../__tests__/hooks/misc.spec.ts | 2 +- .../__tests__/hooks/opening-statement.spec.ts | 2 +- .../__tests__/hooks/sse-callbacks.spec.ts | 2 +- .../workflow/panel/debug-and-preview/hooks.ts | 2 +- .../__tests__/variable-modal.spec.tsx | 4 +- .../panel/env-panel/variable-modal.tsx | 4 +- .../panel/env-panel/variable-trigger.tsx | 4 +- .../workflow/panel/inputs-panel.tsx | 2 +- .../context-menu/__tests__/menu-item.spec.tsx | 2 +- .../context-menu/index.tsx | 8 +- .../context-menu/menu-item.tsx | 2 +- .../delete-confirm-modal.tsx | 2 +- .../panel/version-history-panel/empty.tsx | 2 +- .../filter/filter-switch.tsx | 2 +- .../panel/version-history-panel/index.tsx | 2 +- .../restore-confirm-modal.tsx | 2 +- .../workflow/panel/workflow-preview.tsx | 4 +- .../workflow/run/__tests__/index.spec.tsx | 2 +- .../__tests__/agent-log-nav-more.spec.tsx | 2 +- .../workflow/run/agent-log/agent-log-item.tsx | 2 +- .../run/agent-log/agent-log-nav-more.tsx | 8 +- .../workflow/run/agent-log/agent-log-nav.tsx | 2 +- web/app/components/workflow/run/index.tsx | 2 +- .../iteration-log/iteration-log-trigger.tsx | 2 +- .../run/loop-log/loop-log-trigger.tsx | 2 +- .../run/retry-log/retry-log-trigger.tsx | 2 +- .../workflow/selection-contextmenu.tsx | 16 +- .../components/workflow/update-dsl-modal.tsx | 4 +- .../__tests__/value-content-sections.spec.tsx | 2 +- .../workflow/variable-inspect/group.tsx | 2 +- .../workflow/variable-inspect/left.tsx | 2 +- .../workflow/variable-inspect/listening.tsx | 2 +- .../components/zoom-in-out.tsx | 14 +- .../education-apply/education-apply-page.tsx | 4 +- .../education-apply/expire-notice-modal.tsx | 2 +- web/app/education-apply/user-info.tsx | 4 +- .../education-apply/verify-state-modal.tsx | 2 +- .../forgot-password/ChangePasswordForm.tsx | 4 +- .../forgot-password/ForgotPasswordForm.tsx | 4 +- web/app/init/InitPasswordPopup.tsx | 4 +- web/app/install/installForm.tsx | 4 +- web/app/layout.tsx | 4 +- web/app/reset-password/check-code/page.tsx | 4 +- web/app/reset-password/page.tsx | 4 +- web/app/reset-password/set-password/page.tsx | 4 +- web/app/signin/check-code/page.tsx | 4 +- .../signin/components/mail-and-code-auth.tsx | 4 +- .../components/mail-and-password-auth.tsx | 4 +- web/app/signin/components/social-auth.tsx | 2 +- web/app/signin/components/sso-auth.tsx | 4 +- web/app/signin/invite-settings/page.tsx | 4 +- web/app/signin/normal-form.tsx | 2 +- web/app/signin/one-more-step.tsx | 4 +- web/app/signup/check-code/page.tsx | 4 +- web/app/signup/components/input-mail.tsx | 4 +- web/app/signup/set-password/page.tsx | 4 +- web/context/provider-context-provider.tsx | 2 +- web/docs/overlay-migration.md | 16 +- web/eslint.constants.mjs | 8 +- web/hooks/use-import-dsl.ts | 2 +- web/hooks/use-pay.tsx | 6 +- web/service/base.ts | 2 +- web/service/fetch.spec.ts | 2 +- web/service/fetch.ts | 2 +- web/tailwind.config.ts | 2 + 1056 files changed, 2547 insertions(+), 2069 deletions(-) create mode 100644 packages/dify-ui/.gitignore create mode 100644 packages/dify-ui/.storybook/main.ts create mode 100644 packages/dify-ui/.storybook/preview.tsx create mode 100644 packages/dify-ui/.storybook/storybook.css rename {web/app/components/base/ui => packages/dify-ui/src}/alert-dialog/__tests__/index.spec.tsx (100%) rename {web/app/components/base/ui => packages/dify-ui/src}/alert-dialog/index.stories.tsx (99%) rename {web/app/components/base/ui => packages/dify-ui/src}/alert-dialog/index.tsx (94%) rename {web/app/components/base/ui => packages/dify-ui/src}/avatar/__tests__/index.spec.tsx (100%) rename {web/app/components/base/ui => packages/dify-ui/src}/avatar/index.stories.tsx (97%) rename {web/app/components/base/ui => packages/dify-ui/src}/avatar/index.tsx (98%) rename {web/app/components/base/ui => packages/dify-ui/src}/button/__tests__/index.spec.tsx (100%) rename {web/app/components/base/ui => packages/dify-ui/src}/button/index.stories.tsx (97%) rename {web/app/components/base/ui => packages/dify-ui/src}/button/index.tsx (99%) rename {web/app/components/base/ui => packages/dify-ui/src}/context-menu/__tests__/index.spec.tsx (100%) rename {web/app/components/base/ui => packages/dify-ui/src}/context-menu/index.stories.tsx (99%) rename {web/app/components/base/ui => packages/dify-ui/src}/context-menu/index.tsx (95%) rename {web/app/components/base/ui => packages/dify-ui/src}/dialog/__tests__/index.spec.tsx (100%) rename {web/app/components/base/ui => packages/dify-ui/src}/dialog/index.stories.tsx (99%) rename {web/app/components/base/ui => packages/dify-ui/src}/dialog/index.tsx (98%) rename {web/app/components/base/ui => packages/dify-ui/src}/dropdown-menu/__tests__/index.spec.tsx (100%) rename {web/app/components/base/ui => packages/dify-ui/src}/dropdown-menu/index.stories.tsx (99%) rename {web/app/components/base/ui => packages/dify-ui/src}/dropdown-menu/index.tsx (95%) rename {web/app/components/base/ui => packages/dify-ui/src}/number-field/__tests__/index.spec.tsx (100%) rename {web/app/components/base/ui => packages/dify-ui/src}/number-field/index.stories.tsx (98%) rename {web/app/components/base/ui => packages/dify-ui/src}/number-field/index.tsx (99%) rename {web/app/components/base/ui => packages/dify-ui/src}/overlay-shared.ts (100%) rename {web/app/components/base/ui => packages/dify-ui/src}/placement.ts (100%) rename {web/app/components/base/ui => packages/dify-ui/src}/popover/__tests__/index.spec.tsx (100%) rename {web/app/components/base/ui => packages/dify-ui/src}/popover/index.stories.tsx (97%) rename {web/app/components/base/ui => packages/dify-ui/src}/popover/index.tsx (91%) rename {web/app/components/base/ui => packages/dify-ui/src}/scroll-area/__tests__/index.spec.tsx (100%) rename {web/app/components/base/ui => packages/dify-ui/src}/scroll-area/index.stories.tsx (98%) rename {web/app/components/base/ui => packages/dify-ui/src}/scroll-area/index.tsx (98%) rename {web/app/components/base/ui => packages/dify-ui/src}/select/__tests__/index.spec.tsx (100%) rename {web/app/components/base/ui => packages/dify-ui/src}/select/index.stories.tsx (99%) rename {web/app/components/base/ui => packages/dify-ui/src}/select/index.tsx (96%) rename {web/app/components/base/ui => packages/dify-ui/src}/slider/__tests__/index.spec.tsx (100%) rename {web/app/components/base/ui => packages/dify-ui/src}/slider/index.stories.tsx (96%) rename {web/app/components/base/ui => packages/dify-ui/src}/slider/index.tsx (98%) rename {web/app/components/base => packages/dify-ui/src}/switch/__tests__/index.spec.tsx (99%) rename {web/app/components/base => packages/dify-ui/src}/switch/index.stories.tsx (96%) rename {web/app/components/base => packages/dify-ui/src}/switch/index.tsx (72%) rename {web/app/components/base/ui => packages/dify-ui/src}/toast/__tests__/index.spec.tsx (100%) rename {web/app/components/base/ui => packages/dify-ui/src}/toast/index.stories.tsx (99%) rename {web/app/components/base/ui => packages/dify-ui/src}/toast/index.tsx (99%) rename {web/app/components/base/ui => packages/dify-ui/src}/tooltip/__tests__/index.spec.tsx (100%) rename {web/app/components/base/ui => packages/dify-ui/src}/tooltip/index.stories.tsx (98%) rename {web/app/components/base/ui => packages/dify-ui/src}/tooltip/index.tsx (90%) create mode 100644 packages/dify-ui/tailwind.config.ts create mode 100644 packages/dify-ui/tests/setup.ts create mode 100644 packages/dify-ui/vite.config.ts rename packages/tsconfig/{web.json => react.json} (100%) delete mode 100644 web/app/components/base/switch/skeleton.tsx diff --git a/.github/workflows/web-tests.yml b/.github/workflows/web-tests.yml index f3ab4c62c7..dcee8863ce 100644 --- a/.github/workflows/web-tests.yml +++ b/.github/workflows/web-tests.yml @@ -89,3 +89,34 @@ jobs: flags: web env: CODECOV_TOKEN: ${{ env.CODECOV_TOKEN }} + + dify-ui-test: + name: dify-ui Tests + runs-on: ubuntu-latest + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + defaults: + run: + shell: bash + working-directory: ./packages/dify-ui + + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Setup web environment + uses: ./.github/actions/setup-web + + - name: Run dify-ui tests + run: vp test run --coverage --silent=passed-only + + - name: Report coverage + if: ${{ env.CODECOV_TOKEN != '' }} + uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0 + with: + directory: packages/dify-ui/coverage + flags: dify-ui + env: + CODECOV_TOKEN: ${{ env.CODECOV_TOKEN }} diff --git a/codecov.yml b/codecov.yml index 54ac2a4b36..a506087698 100644 --- a/codecov.yml +++ b/codecov.yml @@ -3,6 +3,10 @@ coverage: project: default: target: auto + # Absorb sub-percent coverage noise (rounding, trivial added lines, CVA variants, etc). + threshold: 1% + # Deleting covered code during refactors/migrations must not regress base. + removed_code_behavior: adjust_base flags: web: @@ -10,6 +14,11 @@ flags: - "web/" carryforward: true + dify-ui: + paths: + - "packages/dify-ui/" + carryforward: true + api: paths: - "api/" diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 173a3a7bd7..477391e2de 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -2113,11 +2113,6 @@ "count": 1 } }, - "web/app/components/base/switch/index.stories.tsx": { - "ts/no-explicit-any": { - "count": 1 - } - }, "web/app/components/base/tab-slider/index.tsx": { "react/set-state-in-effect": { "count": 2 diff --git a/packages/dify-ui/.gitignore b/packages/dify-ui/.gitignore new file mode 100644 index 0000000000..befe881885 --- /dev/null +++ b/packages/dify-ui/.gitignore @@ -0,0 +1,3 @@ +/coverage +/dist +/storybook-static diff --git a/packages/dify-ui/.storybook/main.ts b/packages/dify-ui/.storybook/main.ts new file mode 100644 index 0000000000..c8b7ee8e3f --- /dev/null +++ b/packages/dify-ui/.storybook/main.ts @@ -0,0 +1,27 @@ +import type { StorybookConfig } from '@storybook/react-vite' +import tailwindcss from '@tailwindcss/vite' +import { mergeConfig } from 'vite' + +const config: StorybookConfig = { + stories: ['../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'], + addons: [ + '@storybook/addon-links', + '@storybook/addon-docs', + '@storybook/addon-themes', + '@chromatic-com/storybook', + ], + framework: '@storybook/react-vite', + core: { + disableWhatsNewNotifications: true, + }, + docs: { + defaultName: 'Documentation', + }, + async viteFinal(config) { + return mergeConfig(config, { + plugins: [tailwindcss()], + }) + }, +} + +export default config diff --git a/packages/dify-ui/.storybook/preview.tsx b/packages/dify-ui/.storybook/preview.tsx new file mode 100644 index 0000000000..a5bfc5d8af --- /dev/null +++ b/packages/dify-ui/.storybook/preview.tsx @@ -0,0 +1,31 @@ +import type { Preview } from '@storybook/react-vite' +import { withThemeByDataAttribute } from '@storybook/addon-themes' +import './storybook.css' + +export const decorators = [ + withThemeByDataAttribute({ + themes: { + light: 'light', + dark: 'dark', + }, + defaultTheme: 'light', + attributeName: 'data-theme', + }), +] + +const preview: Preview = { + parameters: { + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/i, + }, + }, + docs: { + toc: true, + }, + }, + tags: ['autodocs'], +} + +export default preview diff --git a/packages/dify-ui/.storybook/storybook.css b/packages/dify-ui/.storybook/storybook.css new file mode 100644 index 0000000000..e9796fd046 --- /dev/null +++ b/packages/dify-ui/.storybook/storybook.css @@ -0,0 +1,19 @@ +@import 'tailwindcss'; + +@config '../tailwind.config.ts'; + +@import '../src/styles/styles.css'; + +html { + color-scheme: light; +} + +html[data-theme='dark'] { + color-scheme: dark; +} + +body { + background: var(--color-components-panel-bg); + color: var(--color-text-primary, #101828); + font-family: Inter, ui-sans-serif, system-ui, sans-serif; +} diff --git a/packages/dify-ui/AGENTS.md b/packages/dify-ui/AGENTS.md index ecc968e130..651b117070 100644 --- a/packages/dify-ui/AGENTS.md +++ b/packages/dify-ui/AGENTS.md @@ -1,6 +1,15 @@ # @langgenius/dify-ui -This package provides shared design tokens (colors, shadows, typography), the `cn()` utility, and a Tailwind CSS preset consumed by `web/`. +Shared design tokens, the `cn()` utility, a Tailwind CSS preset, and headless primitive components consumed by `web/`. + +## Component Authoring Rules + +- Use `@base-ui/react` primitives + `cva` + `cn`. +- Inside dify-ui, cross-component imports use relative paths (`../button`). External consumers use subpath exports (`@langgenius/dify-ui/button`). +- No imports from `web/`. No dependencies on next / i18next / ky / jotai / zustand. +- One component per folder: `src//index.tsx`, optional `index.stories.tsx` and `__tests__/index.spec.tsx`. Add a matching `./` subpath to `package.json#exports`. +- Props pattern: `Omit & VariantProps & { /* custom */ }`. +- When a component accepts a prop typed from a shared internal module, `export type` it from that component so consumers import it from the component subpath. ## Border Radius: Figma Token → Tailwind Class Mapping diff --git a/packages/dify-ui/package.json b/packages/dify-ui/package.json index b54fde9b89..2b78b25ed6 100644 --- a/packages/dify-ui/package.json +++ b/packages/dify-ui/package.json @@ -12,18 +12,109 @@ "./cn": { "types": "./src/cn.ts", "import": "./src/cn.ts" + }, + "./alert-dialog": { + "types": "./src/alert-dialog/index.tsx", + "import": "./src/alert-dialog/index.tsx" + }, + "./avatar": { + "types": "./src/avatar/index.tsx", + "import": "./src/avatar/index.tsx" + }, + "./button": { + "types": "./src/button/index.tsx", + "import": "./src/button/index.tsx" + }, + "./context-menu": { + "types": "./src/context-menu/index.tsx", + "import": "./src/context-menu/index.tsx" + }, + "./dialog": { + "types": "./src/dialog/index.tsx", + "import": "./src/dialog/index.tsx" + }, + "./dropdown-menu": { + "types": "./src/dropdown-menu/index.tsx", + "import": "./src/dropdown-menu/index.tsx" + }, + "./number-field": { + "types": "./src/number-field/index.tsx", + "import": "./src/number-field/index.tsx" + }, + "./popover": { + "types": "./src/popover/index.tsx", + "import": "./src/popover/index.tsx" + }, + "./scroll-area": { + "types": "./src/scroll-area/index.tsx", + "import": "./src/scroll-area/index.tsx" + }, + "./select": { + "types": "./src/select/index.tsx", + "import": "./src/select/index.tsx" + }, + "./slider": { + "types": "./src/slider/index.tsx", + "import": "./src/slider/index.tsx" + }, + "./switch": { + "types": "./src/switch/index.tsx", + "import": "./src/switch/index.tsx" + }, + "./toast": { + "types": "./src/toast/index.tsx", + "import": "./src/toast/index.tsx" + }, + "./tooltip": { + "types": "./src/tooltip/index.tsx", + "import": "./src/tooltip/index.tsx" } }, "scripts": { + "storybook": "storybook dev", + "storybook:build": "storybook build", + "test": "vp test", + "test:watch": "vp test --watch", "type-check": "tsc" }, + "peerDependencies": { + "@base-ui/react": "catalog:", + "class-variance-authority": "catalog:", + "react": "catalog:", + "react-dom": "catalog:", + "tailwindcss": "catalog:" + }, "dependencies": { "clsx": "catalog:", "tailwind-merge": "catalog:" }, "devDependencies": { + "@base-ui/react": "catalog:", + "@chromatic-com/storybook": "catalog:", "@dify/tsconfig": "workspace:*", + "@egoist/tailwindcss-icons": "catalog:", + "@iconify-json/ri": "catalog:", + "@storybook/addon-docs": "catalog:", + "@storybook/addon-links": "catalog:", + "@storybook/addon-themes": "catalog:", + "@storybook/react-vite": "catalog:", + "@tailwindcss/vite": "catalog:", + "@testing-library/jest-dom": "catalog:", + "@testing-library/react": "catalog:", + "@testing-library/user-event": "catalog:", + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "@vitejs/plugin-react": "catalog:", + "@vitest/coverage-v8": "catalog:", + "class-variance-authority": "catalog:", + "happy-dom": "catalog:", + "react": "catalog:", + "react-dom": "catalog:", + "storybook": "catalog:", "tailwindcss": "catalog:", - "typescript": "catalog:" + "typescript": "catalog:", + "vite": "catalog:", + "vite-plus": "catalog:", + "vitest": "catalog:" } } diff --git a/web/app/components/base/ui/alert-dialog/__tests__/index.spec.tsx b/packages/dify-ui/src/alert-dialog/__tests__/index.spec.tsx similarity index 100% rename from web/app/components/base/ui/alert-dialog/__tests__/index.spec.tsx rename to packages/dify-ui/src/alert-dialog/__tests__/index.spec.tsx diff --git a/web/app/components/base/ui/alert-dialog/index.stories.tsx b/packages/dify-ui/src/alert-dialog/index.stories.tsx similarity index 99% rename from web/app/components/base/ui/alert-dialog/index.stories.tsx rename to packages/dify-ui/src/alert-dialog/index.stories.tsx index c9deaa53ed..0b6f60f01e 100644 --- a/web/app/components/base/ui/alert-dialog/index.stories.tsx +++ b/packages/dify-ui/src/alert-dialog/index.stories.tsx @@ -1,4 +1,4 @@ -import type { Meta, StoryObj } from '@storybook/nextjs-vite' +import type { Meta, StoryObj } from '@storybook/react-vite' import { useState } from 'react' import { AlertDialog, diff --git a/web/app/components/base/ui/alert-dialog/index.tsx b/packages/dify-ui/src/alert-dialog/index.tsx similarity index 94% rename from web/app/components/base/ui/alert-dialog/index.tsx rename to packages/dify-ui/src/alert-dialog/index.tsx index 5f2af43df8..7b432c87dc 100644 --- a/web/app/components/base/ui/alert-dialog/index.tsx +++ b/packages/dify-ui/src/alert-dialog/index.tsx @@ -1,10 +1,10 @@ 'use client' import type { ComponentPropsWithoutRef, ReactNode } from 'react' -import type { ButtonProps } from '@/app/components/base/ui/button' +import type { ButtonProps } from '../button' import { AlertDialog as BaseAlertDialog } from '@base-ui/react/alert-dialog' -import { cn } from '@langgenius/dify-ui/cn' -import { Button } from '@/app/components/base/ui/button' +import { Button } from '../button' +import { cn } from '../cn' export const AlertDialog = BaseAlertDialog.Root export const AlertDialogTrigger = BaseAlertDialog.Trigger diff --git a/web/app/components/base/ui/avatar/__tests__/index.spec.tsx b/packages/dify-ui/src/avatar/__tests__/index.spec.tsx similarity index 100% rename from web/app/components/base/ui/avatar/__tests__/index.spec.tsx rename to packages/dify-ui/src/avatar/__tests__/index.spec.tsx diff --git a/web/app/components/base/ui/avatar/index.stories.tsx b/packages/dify-ui/src/avatar/index.stories.tsx similarity index 97% rename from web/app/components/base/ui/avatar/index.stories.tsx rename to packages/dify-ui/src/avatar/index.stories.tsx index 22de82e6db..f8b90c0b16 100644 --- a/web/app/components/base/ui/avatar/index.stories.tsx +++ b/packages/dify-ui/src/avatar/index.stories.tsx @@ -1,4 +1,4 @@ -import type { Meta, StoryObj } from '@storybook/nextjs-vite' +import type { Meta, StoryObj } from '@storybook/react-vite' import { Avatar, AvatarFallback, AvatarRoot } from '.' const meta = { diff --git a/web/app/components/base/ui/avatar/index.tsx b/packages/dify-ui/src/avatar/index.tsx similarity index 98% rename from web/app/components/base/ui/avatar/index.tsx rename to packages/dify-ui/src/avatar/index.tsx index 6fca2e87dc..8cf893fbbd 100644 --- a/web/app/components/base/ui/avatar/index.tsx +++ b/packages/dify-ui/src/avatar/index.tsx @@ -1,6 +1,6 @@ import type { ImageLoadingStatus } from '@base-ui/react/avatar' import { Avatar as BaseAvatar } from '@base-ui/react/avatar' -import { cn } from '@langgenius/dify-ui/cn' +import { cn } from '../cn' const avatarSizeClasses = { 'xxs': { root: 'size-4', text: 'text-[7px]' }, diff --git a/web/app/components/base/ui/button/__tests__/index.spec.tsx b/packages/dify-ui/src/button/__tests__/index.spec.tsx similarity index 100% rename from web/app/components/base/ui/button/__tests__/index.spec.tsx rename to packages/dify-ui/src/button/__tests__/index.spec.tsx diff --git a/web/app/components/base/ui/button/index.stories.tsx b/packages/dify-ui/src/button/index.stories.tsx similarity index 97% rename from web/app/components/base/ui/button/index.stories.tsx rename to packages/dify-ui/src/button/index.stories.tsx index 40dce31dd4..b70a76f431 100644 --- a/web/app/components/base/ui/button/index.stories.tsx +++ b/packages/dify-ui/src/button/index.stories.tsx @@ -1,4 +1,4 @@ -import type { Meta, StoryObj } from '@storybook/nextjs-vite' +import type { Meta, StoryObj } from '@storybook/react-vite' import { Button } from '.' diff --git a/web/app/components/base/ui/button/index.tsx b/packages/dify-ui/src/button/index.tsx similarity index 99% rename from web/app/components/base/ui/button/index.tsx rename to packages/dify-ui/src/button/index.tsx index 73d5ecc3c9..03e5c4a937 100644 --- a/web/app/components/base/ui/button/index.tsx +++ b/packages/dify-ui/src/button/index.tsx @@ -1,8 +1,8 @@ import type { Button as BaseButtonNS } from '@base-ui/react/button' import type { VariantProps } from 'class-variance-authority' import { Button as BaseButton } from '@base-ui/react/button' -import { cn } from '@langgenius/dify-ui/cn' import { cva } from 'class-variance-authority' +import { cn } from '../cn' const buttonVariants = cva( 'inline-flex cursor-pointer items-center justify-center whitespace-nowrap outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid data-[disabled]:cursor-not-allowed', diff --git a/web/app/components/base/ui/context-menu/__tests__/index.spec.tsx b/packages/dify-ui/src/context-menu/__tests__/index.spec.tsx similarity index 100% rename from web/app/components/base/ui/context-menu/__tests__/index.spec.tsx rename to packages/dify-ui/src/context-menu/__tests__/index.spec.tsx diff --git a/web/app/components/base/ui/context-menu/index.stories.tsx b/packages/dify-ui/src/context-menu/index.stories.tsx similarity index 99% rename from web/app/components/base/ui/context-menu/index.stories.tsx rename to packages/dify-ui/src/context-menu/index.stories.tsx index b3b43399f6..0be5727e7a 100644 --- a/web/app/components/base/ui/context-menu/index.stories.tsx +++ b/packages/dify-ui/src/context-menu/index.stories.tsx @@ -1,4 +1,4 @@ -import type { Meta, StoryObj } from '@storybook/nextjs-vite' +import type { Meta, StoryObj } from '@storybook/react-vite' import { useState } from 'react' import { ContextMenu, diff --git a/web/app/components/base/ui/context-menu/index.tsx b/packages/dify-ui/src/context-menu/index.tsx similarity index 95% rename from web/app/components/base/ui/context-menu/index.tsx rename to packages/dify-ui/src/context-menu/index.tsx index c92c4d6ab9..fc2ae1d454 100644 --- a/web/app/components/base/ui/context-menu/index.tsx +++ b/packages/dify-ui/src/context-menu/index.tsx @@ -1,10 +1,10 @@ 'use client' import type { ReactNode } from 'react' -import type { OverlayItemVariant } from '@/app/components/base/ui/overlay-shared' -import type { Placement } from '@/app/components/base/ui/placement' +import type { OverlayItemVariant } from '../overlay-shared' +import type { Placement } from '../placement' import { ContextMenu as BaseContextMenu } from '@base-ui/react/context-menu' -import { cn } from '@langgenius/dify-ui/cn' +import { cn } from '../cn' import { overlayBackdropClassName, overlayDestructiveClassName, @@ -14,8 +14,11 @@ import { overlayPopupBaseClassName, overlayRowClassName, overlaySeparatorClassName, -} from '@/app/components/base/ui/overlay-shared' -import { parsePlacement } from '@/app/components/base/ui/placement' +} from '../overlay-shared' +import { parsePlacement } from '../placement' + +/** @public */ +export type { Placement } export const ContextMenu = BaseContextMenu.Root export const ContextMenuTrigger = BaseContextMenu.Trigger diff --git a/web/app/components/base/ui/dialog/__tests__/index.spec.tsx b/packages/dify-ui/src/dialog/__tests__/index.spec.tsx similarity index 100% rename from web/app/components/base/ui/dialog/__tests__/index.spec.tsx rename to packages/dify-ui/src/dialog/__tests__/index.spec.tsx diff --git a/web/app/components/base/ui/dialog/index.stories.tsx b/packages/dify-ui/src/dialog/index.stories.tsx similarity index 99% rename from web/app/components/base/ui/dialog/index.stories.tsx rename to packages/dify-ui/src/dialog/index.stories.tsx index 0e8f478520..f9caa0d8c5 100644 --- a/web/app/components/base/ui/dialog/index.stories.tsx +++ b/packages/dify-ui/src/dialog/index.stories.tsx @@ -1,4 +1,4 @@ -import type { Meta, StoryObj } from '@storybook/nextjs-vite' +import type { Meta, StoryObj } from '@storybook/react-vite' import { useState } from 'react' import { Dialog, diff --git a/web/app/components/base/ui/dialog/index.tsx b/packages/dify-ui/src/dialog/index.tsx similarity index 98% rename from web/app/components/base/ui/dialog/index.tsx rename to packages/dify-ui/src/dialog/index.tsx index bc82230f58..c24acd5924 100644 --- a/web/app/components/base/ui/dialog/index.tsx +++ b/packages/dify-ui/src/dialog/index.tsx @@ -9,7 +9,7 @@ import type { ReactNode } from 'react' import { Dialog as BaseDialog } from '@base-ui/react/dialog' -import { cn } from '@langgenius/dify-ui/cn' +import { cn } from '../cn' export const Dialog = BaseDialog.Root /** @public */ diff --git a/web/app/components/base/ui/dropdown-menu/__tests__/index.spec.tsx b/packages/dify-ui/src/dropdown-menu/__tests__/index.spec.tsx similarity index 100% rename from web/app/components/base/ui/dropdown-menu/__tests__/index.spec.tsx rename to packages/dify-ui/src/dropdown-menu/__tests__/index.spec.tsx diff --git a/web/app/components/base/ui/dropdown-menu/index.stories.tsx b/packages/dify-ui/src/dropdown-menu/index.stories.tsx similarity index 99% rename from web/app/components/base/ui/dropdown-menu/index.stories.tsx rename to packages/dify-ui/src/dropdown-menu/index.stories.tsx index ae0ad61c68..f73b33ac8b 100644 --- a/web/app/components/base/ui/dropdown-menu/index.stories.tsx +++ b/packages/dify-ui/src/dropdown-menu/index.stories.tsx @@ -1,4 +1,4 @@ -import type { Meta, StoryObj } from '@storybook/nextjs-vite' +import type { Meta, StoryObj } from '@storybook/react-vite' import { useState } from 'react' import { DropdownMenu, diff --git a/web/app/components/base/ui/dropdown-menu/index.tsx b/packages/dify-ui/src/dropdown-menu/index.tsx similarity index 95% rename from web/app/components/base/ui/dropdown-menu/index.tsx rename to packages/dify-ui/src/dropdown-menu/index.tsx index b0c034f3cb..f742625964 100644 --- a/web/app/components/base/ui/dropdown-menu/index.tsx +++ b/packages/dify-ui/src/dropdown-menu/index.tsx @@ -1,10 +1,10 @@ 'use client' import type { ReactNode } from 'react' -import type { OverlayItemVariant } from '@/app/components/base/ui/overlay-shared' -import type { Placement } from '@/app/components/base/ui/placement' +import type { OverlayItemVariant } from '../overlay-shared' +import type { Placement } from '../placement' import { Menu } from '@base-ui/react/menu' -import { cn } from '@langgenius/dify-ui/cn' +import { cn } from '../cn' import { overlayDestructiveClassName, overlayIndicatorClassName, @@ -13,8 +13,10 @@ import { overlayPopupBaseClassName, overlayRowClassName, overlaySeparatorClassName, -} from '@/app/components/base/ui/overlay-shared' -import { parsePlacement } from '@/app/components/base/ui/placement' +} from '../overlay-shared' +import { parsePlacement } from '../placement' + +export type { Placement } export const DropdownMenu = Menu.Root export const DropdownMenuTrigger = Menu.Trigger diff --git a/web/app/components/base/ui/number-field/__tests__/index.spec.tsx b/packages/dify-ui/src/number-field/__tests__/index.spec.tsx similarity index 100% rename from web/app/components/base/ui/number-field/__tests__/index.spec.tsx rename to packages/dify-ui/src/number-field/__tests__/index.spec.tsx diff --git a/web/app/components/base/ui/number-field/index.stories.tsx b/packages/dify-ui/src/number-field/index.stories.tsx similarity index 98% rename from web/app/components/base/ui/number-field/index.stories.tsx rename to packages/dify-ui/src/number-field/index.stories.tsx index 6abb93bf11..a436d997e6 100644 --- a/web/app/components/base/ui/number-field/index.stories.tsx +++ b/packages/dify-ui/src/number-field/index.stories.tsx @@ -1,5 +1,4 @@ -import type { Meta, StoryObj } from '@storybook/nextjs-vite' -import { cn } from '@langgenius/dify-ui/cn' +import type { Meta, StoryObj } from '@storybook/react-vite' import { useId, useState } from 'react' import { NumberField, @@ -10,6 +9,7 @@ import { NumberFieldInput, NumberFieldUnit, } from '.' +import { cn } from '../cn' type DemoFieldProps = { label: string diff --git a/web/app/components/base/ui/number-field/index.tsx b/packages/dify-ui/src/number-field/index.tsx similarity index 99% rename from web/app/components/base/ui/number-field/index.tsx rename to packages/dify-ui/src/number-field/index.tsx index 944aa52470..70e66dd91b 100644 --- a/web/app/components/base/ui/number-field/index.tsx +++ b/packages/dify-ui/src/number-field/index.tsx @@ -3,8 +3,8 @@ import type { VariantProps } from 'class-variance-authority' import type { HTMLAttributes } from 'react' import { NumberField as BaseNumberField } from '@base-ui/react/number-field' -import { cn } from '@langgenius/dify-ui/cn' import { cva } from 'class-variance-authority' +import { cn } from '../cn' export const NumberField = BaseNumberField.Root export type NumberFieldRootProps = BaseNumberField.Root.Props diff --git a/web/app/components/base/ui/overlay-shared.ts b/packages/dify-ui/src/overlay-shared.ts similarity index 100% rename from web/app/components/base/ui/overlay-shared.ts rename to packages/dify-ui/src/overlay-shared.ts diff --git a/web/app/components/base/ui/placement.ts b/packages/dify-ui/src/placement.ts similarity index 100% rename from web/app/components/base/ui/placement.ts rename to packages/dify-ui/src/placement.ts diff --git a/web/app/components/base/ui/popover/__tests__/index.spec.tsx b/packages/dify-ui/src/popover/__tests__/index.spec.tsx similarity index 100% rename from web/app/components/base/ui/popover/__tests__/index.spec.tsx rename to packages/dify-ui/src/popover/__tests__/index.spec.tsx diff --git a/web/app/components/base/ui/popover/index.stories.tsx b/packages/dify-ui/src/popover/index.stories.tsx similarity index 97% rename from web/app/components/base/ui/popover/index.stories.tsx rename to packages/dify-ui/src/popover/index.stories.tsx index 8dbae184de..dcea5018ab 100644 --- a/web/app/components/base/ui/popover/index.stories.tsx +++ b/packages/dify-ui/src/popover/index.stories.tsx @@ -1,5 +1,6 @@ -import type { Meta, StoryObj } from '@storybook/nextjs-vite' -import type { Placement } from '../placement' +import type { Meta, StoryObj } from '@storybook/react-vite' +import type { Placement } from '.' +import { Button } from '@langgenius/dify-ui/button' import { useState } from 'react' import { Popover, @@ -9,7 +10,6 @@ import { PopoverTitle, PopoverTrigger, } from '.' -import { Button } from '../button' const triggerButtonClassName = 'rounded-lg border border-divider-subtle bg-components-button-secondary-bg px-3 py-1.5 text-sm text-text-secondary shadow-xs hover:bg-state-base-hover' diff --git a/web/app/components/base/ui/popover/index.tsx b/packages/dify-ui/src/popover/index.tsx similarity index 91% rename from web/app/components/base/ui/popover/index.tsx rename to packages/dify-ui/src/popover/index.tsx index dbde17ae21..32d111a1b8 100644 --- a/web/app/components/base/ui/popover/index.tsx +++ b/packages/dify-ui/src/popover/index.tsx @@ -1,10 +1,12 @@ 'use client' import type { ReactNode } from 'react' -import type { Placement } from '@/app/components/base/ui/placement' +import type { Placement } from '../placement' import { Popover as BasePopover } from '@base-ui/react/popover' -import { cn } from '@langgenius/dify-ui/cn' -import { parsePlacement } from '@/app/components/base/ui/placement' +import { cn } from '../cn' +import { parsePlacement } from '../placement' + +export type { Placement } export const Popover = BasePopover.Root export const PopoverTrigger = BasePopover.Trigger diff --git a/web/app/components/base/ui/scroll-area/__tests__/index.spec.tsx b/packages/dify-ui/src/scroll-area/__tests__/index.spec.tsx similarity index 100% rename from web/app/components/base/ui/scroll-area/__tests__/index.spec.tsx rename to packages/dify-ui/src/scroll-area/__tests__/index.spec.tsx diff --git a/web/app/components/base/ui/scroll-area/index.stories.tsx b/packages/dify-ui/src/scroll-area/index.stories.tsx similarity index 98% rename from web/app/components/base/ui/scroll-area/index.stories.tsx rename to packages/dify-ui/src/scroll-area/index.stories.tsx index dbe8161f8f..e1f8f9cfb5 100644 --- a/web/app/components/base/ui/scroll-area/index.stories.tsx +++ b/packages/dify-ui/src/scroll-area/index.stories.tsx @@ -1,8 +1,6 @@ -import type { Meta, StoryObj } from '@storybook/nextjs-vite' +import type { Meta, StoryObj } from '@storybook/react-vite' import type { ReactNode } from 'react' -import { cn } from '@langgenius/dify-ui/cn' import * as React from 'react' -import AppIcon from '@/app/components/base/app-icon' import { ScrollAreaContent, ScrollAreaCorner, @@ -11,6 +9,7 @@ import { ScrollAreaThumb, ScrollAreaViewport, } from '.' +import { cn } from '../cn' const meta = { title: 'Base/UI/ScrollArea', @@ -490,12 +489,13 @@ const ExploreSidebarWebAppsPane = () => { )} >
- +
+ {item.icon} +
{item.name} diff --git a/web/app/components/base/ui/scroll-area/index.tsx b/packages/dify-ui/src/scroll-area/index.tsx similarity index 98% rename from web/app/components/base/ui/scroll-area/index.tsx rename to packages/dify-ui/src/scroll-area/index.tsx index 300946b1bf..4e1832e661 100644 --- a/web/app/components/base/ui/scroll-area/index.tsx +++ b/packages/dify-ui/src/scroll-area/index.tsx @@ -2,7 +2,7 @@ import type { ReactNode } from 'react' import { ScrollArea as BaseScrollArea } from '@base-ui/react/scroll-area' -import { cn } from '@langgenius/dify-ui/cn' +import { cn } from '../cn' export const ScrollAreaRoot = BaseScrollArea.Root type ScrollAreaRootProps = BaseScrollArea.Root.Props diff --git a/web/app/components/base/ui/select/__tests__/index.spec.tsx b/packages/dify-ui/src/select/__tests__/index.spec.tsx similarity index 100% rename from web/app/components/base/ui/select/__tests__/index.spec.tsx rename to packages/dify-ui/src/select/__tests__/index.spec.tsx diff --git a/web/app/components/base/ui/select/index.stories.tsx b/packages/dify-ui/src/select/index.stories.tsx similarity index 99% rename from web/app/components/base/ui/select/index.stories.tsx rename to packages/dify-ui/src/select/index.stories.tsx index 027d0f74df..6dc832a291 100644 --- a/web/app/components/base/ui/select/index.stories.tsx +++ b/packages/dify-ui/src/select/index.stories.tsx @@ -1,4 +1,4 @@ -import type { Meta, StoryObj } from '@storybook/nextjs-vite' +import type { Meta, StoryObj } from '@storybook/react-vite' import { useState } from 'react' import { Select, diff --git a/web/app/components/base/ui/select/index.tsx b/packages/dify-ui/src/select/index.tsx similarity index 96% rename from web/app/components/base/ui/select/index.tsx rename to packages/dify-ui/src/select/index.tsx index 7de0088af4..62478fad21 100644 --- a/web/app/components/base/ui/select/index.tsx +++ b/packages/dify-ui/src/select/index.tsx @@ -2,15 +2,18 @@ import type { VariantProps } from 'class-variance-authority' import type { ReactNode } from 'react' -import type { Placement } from '@/app/components/base/ui/placement' +import type { Placement } from '../placement' import { Select as BaseSelect } from '@base-ui/react/select' -import { cn } from '@langgenius/dify-ui/cn' import { cva } from 'class-variance-authority' +import { cn } from '../cn' import { overlayLabelClassName, overlaySeparatorClassName, -} from '@/app/components/base/ui/overlay-shared' -import { parsePlacement } from '@/app/components/base/ui/placement' +} from '../overlay-shared' +import { parsePlacement } from '../placement' + +/** @public */ +export type { Placement } export const Select = BaseSelect.Root export const SelectValue = BaseSelect.Value diff --git a/web/app/components/base/ui/slider/__tests__/index.spec.tsx b/packages/dify-ui/src/slider/__tests__/index.spec.tsx similarity index 100% rename from web/app/components/base/ui/slider/__tests__/index.spec.tsx rename to packages/dify-ui/src/slider/__tests__/index.spec.tsx diff --git a/web/app/components/base/ui/slider/index.stories.tsx b/packages/dify-ui/src/slider/index.stories.tsx similarity index 96% rename from web/app/components/base/ui/slider/index.stories.tsx rename to packages/dify-ui/src/slider/index.stories.tsx index 91bc0d3ecb..a48e202142 100644 --- a/web/app/components/base/ui/slider/index.stories.tsx +++ b/packages/dify-ui/src/slider/index.stories.tsx @@ -1,4 +1,4 @@ -import type { Meta, StoryObj } from '@storybook/nextjs-vite' +import type { Meta, StoryObj } from '@storybook/react-vite' import type * as React from 'react' import { useState } from 'react' import { Slider } from '.' diff --git a/web/app/components/base/ui/slider/index.tsx b/packages/dify-ui/src/slider/index.tsx similarity index 98% rename from web/app/components/base/ui/slider/index.tsx rename to packages/dify-ui/src/slider/index.tsx index ad1bc0ba8f..cfe1361598 100644 --- a/web/app/components/base/ui/slider/index.tsx +++ b/packages/dify-ui/src/slider/index.tsx @@ -1,7 +1,7 @@ 'use client' import { Slider as BaseSlider } from '@base-ui/react/slider' -import { cn } from '@langgenius/dify-ui/cn' +import { cn } from '../cn' /** @public */ export const SliderRoot = BaseSlider.Root diff --git a/web/app/components/base/switch/__tests__/index.spec.tsx b/packages/dify-ui/src/switch/__tests__/index.spec.tsx similarity index 99% rename from web/app/components/base/switch/__tests__/index.spec.tsx rename to packages/dify-ui/src/switch/__tests__/index.spec.tsx index 0d46095ebd..055b6e0791 100644 --- a/web/app/components/base/switch/__tests__/index.spec.tsx +++ b/packages/dify-ui/src/switch/__tests__/index.spec.tsx @@ -1,8 +1,7 @@ import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { describe, expect, it, vi } from 'vitest' -import Switch from '../index' -import { SwitchSkeleton } from '../skeleton' +import { Switch, SwitchSkeleton } from '../index' const getThumb = (switchElement: HTMLElement) => switchElement.querySelector('span') diff --git a/web/app/components/base/switch/index.stories.tsx b/packages/dify-ui/src/switch/index.stories.tsx similarity index 96% rename from web/app/components/base/switch/index.stories.tsx rename to packages/dify-ui/src/switch/index.stories.tsx index 1b3a52dcc4..f43b9ae154 100644 --- a/web/app/components/base/switch/index.stories.tsx +++ b/packages/dify-ui/src/switch/index.stories.tsx @@ -1,16 +1,16 @@ -import type { Meta, StoryObj } from '@storybook/nextjs-vite' +import type { Meta, StoryObj } from '@storybook/react-vite' +import type { ComponentProps } from 'react' import { useState, useTransition } from 'react' -import Switch from '.' -import { SwitchSkeleton } from './skeleton' +import { Switch, SwitchSkeleton } from '.' const meta = { - title: 'Base/Data Entry/Switch', + title: 'Base/UI/Switch', component: Switch, parameters: { layout: 'centered', docs: { description: { - component: 'Toggle switch built on Base UI with CVA variants, Figma-aligned design tokens, loading spinner, and skeleton placeholder. Import `Switch` for the toggle and `SwitchSkeleton` from `./skeleton` for loading placeholders.', + component: 'Toggle switch built on Base UI with CVA variants, Figma-aligned design tokens, loading spinner, and skeleton placeholder. Import `Switch` and `SwitchSkeleton` from `@langgenius/dify-ui/switch`.', }, }, }, @@ -42,7 +42,7 @@ const meta = { export default meta type Story = StoryObj -const SwitchDemo = (args: any) => { +const SwitchDemo = (args: Partial>) => { const [enabled, setEnabled] = useState(args.checked ?? false) return ( @@ -338,7 +338,7 @@ export const Skeleton: Story = { parameters: { docs: { description: { - story: '`SwitchSkeleton` renders a non-interactive placeholder with `bg-text-quaternary opacity-20`. Imported separately from `./skeleton`.', + story: '`SwitchSkeleton` renders a non-interactive placeholder with `bg-text-quaternary opacity-20`. Exported from `@langgenius/dify-ui/switch` alongside `Switch`.', }, }, }, diff --git a/web/app/components/base/switch/index.tsx b/packages/dify-ui/src/switch/index.tsx similarity index 72% rename from web/app/components/base/switch/index.tsx rename to packages/dify-ui/src/switch/index.tsx index 942a2291d0..dd15ef6f79 100644 --- a/web/app/components/base/switch/index.tsx +++ b/packages/dify-ui/src/switch/index.tsx @@ -1,10 +1,11 @@ 'use client' +import type { Switch as BaseSwitchNS } from '@base-ui/react/switch' import type { VariantProps } from 'class-variance-authority' +import type { HTMLAttributes } from 'react' import { Switch as BaseSwitch } from '@base-ui/react/switch' -import { cn } from '@langgenius/dify-ui/cn' import { cva } from 'class-variance-authority' -import * as React from 'react' +import { cn } from '../cn' const switchRootStateClassName = 'bg-components-toggle-bg-unchecked hover:bg-components-toggle-bg-unchecked-hover data-checked:bg-components-toggle-bg data-checked:hover:bg-components-toggle-bg-hover data-disabled:cursor-not-allowed data-disabled:bg-components-toggle-bg-unchecked-disabled data-disabled:hover:bg-components-toggle-bg-unchecked-disabled data-disabled:data-checked:bg-components-toggle-bg-disabled data-disabled:data-checked:hover:bg-components-toggle-bg-disabled' @@ -61,46 +62,35 @@ const spinnerSizeConfig: Partial void - 'size'?: SwitchSize - 'disabled'?: boolean - 'loading'?: boolean - 'className'?: string - 'aria-label'?: string - 'aria-labelledby'?: string - 'data-testid'?: string -} +export type SwitchProps + = Omit + & VariantProps + & { + onCheckedChange?: (checked: boolean) => void + loading?: boolean + className?: string + } -const Switch = ({ - ref, +export function Switch({ checked, - onCheckedChange, size = 'md', - disabled = false, + disabled, loading = false, className, - 'aria-label': ariaLabel, - 'aria-labelledby': ariaLabelledBy, - 'data-testid': dataTestid, -}: SwitchProps & { - ref?: React.Ref -}) => { + onCheckedChange, + ...props +}: SwitchProps) { const isDisabled = disabled || loading - const spinner = loading ? spinnerSizeConfig[size] : undefined + const spinner = loading && size ? spinnerSizeConfig[size] : undefined return ( onCheckedChange?.(value)} disabled={isDisabled} aria-busy={loading || undefined} - aria-label={ariaLabel} - aria-labelledby={ariaLabelledBy} className={cn(switchRootVariants({ size }), className)} - data-testid={dataTestid} + onCheckedChange={value => onCheckedChange?.(value)} + {...props} > , 'className'> + & VariantProps + & { + className?: string + } + +export function SwitchSkeleton({ + size = 'md', + className, + ...props +}: SwitchSkeletonProps) { + return ( +
+ ) +} diff --git a/web/app/components/base/ui/toast/__tests__/index.spec.tsx b/packages/dify-ui/src/toast/__tests__/index.spec.tsx similarity index 100% rename from web/app/components/base/ui/toast/__tests__/index.spec.tsx rename to packages/dify-ui/src/toast/__tests__/index.spec.tsx diff --git a/web/app/components/base/ui/toast/index.stories.tsx b/packages/dify-ui/src/toast/index.stories.tsx similarity index 99% rename from web/app/components/base/ui/toast/index.stories.tsx rename to packages/dify-ui/src/toast/index.stories.tsx index d292e3434d..cbfb944f19 100644 --- a/web/app/components/base/ui/toast/index.stories.tsx +++ b/packages/dify-ui/src/toast/index.stories.tsx @@ -1,4 +1,4 @@ -import type { Meta, StoryObj } from '@storybook/nextjs-vite' +import type { Meta, StoryObj } from '@storybook/react-vite' import type { ReactNode } from 'react' import { toast } from '.' diff --git a/web/app/components/base/ui/toast/index.tsx b/packages/dify-ui/src/toast/index.tsx similarity index 99% rename from web/app/components/base/ui/toast/index.tsx rename to packages/dify-ui/src/toast/index.tsx index 1f9fea5173..a479621563 100644 --- a/web/app/components/base/ui/toast/index.tsx +++ b/packages/dify-ui/src/toast/index.tsx @@ -7,7 +7,7 @@ import type { } from '@base-ui/react/toast' import type { ReactNode } from 'react' import { Toast as BaseToast } from '@base-ui/react/toast' -import { cn } from '@langgenius/dify-ui/cn' +import { cn } from '../cn' type ToastData = Record type ToastToneStyle = { diff --git a/web/app/components/base/ui/tooltip/__tests__/index.spec.tsx b/packages/dify-ui/src/tooltip/__tests__/index.spec.tsx similarity index 100% rename from web/app/components/base/ui/tooltip/__tests__/index.spec.tsx rename to packages/dify-ui/src/tooltip/__tests__/index.spec.tsx diff --git a/web/app/components/base/ui/tooltip/index.stories.tsx b/packages/dify-ui/src/tooltip/index.stories.tsx similarity index 98% rename from web/app/components/base/ui/tooltip/index.stories.tsx rename to packages/dify-ui/src/tooltip/index.stories.tsx index 16d0675c44..dca3be32f3 100644 --- a/web/app/components/base/ui/tooltip/index.stories.tsx +++ b/packages/dify-ui/src/tooltip/index.stories.tsx @@ -1,5 +1,5 @@ -import type { Meta, StoryObj } from '@storybook/nextjs-vite' -import type { Placement } from '../placement' +import type { Meta, StoryObj } from '@storybook/react-vite' +import type { Placement } from '.' import { useState } from 'react' import { Tooltip, diff --git a/web/app/components/base/ui/tooltip/index.tsx b/packages/dify-ui/src/tooltip/index.tsx similarity index 90% rename from web/app/components/base/ui/tooltip/index.tsx rename to packages/dify-ui/src/tooltip/index.tsx index b84921e351..e0fcd7c5c3 100644 --- a/web/app/components/base/ui/tooltip/index.tsx +++ b/packages/dify-ui/src/tooltip/index.tsx @@ -1,10 +1,12 @@ 'use client' import type { ReactNode } from 'react' -import type { Placement } from '@/app/components/base/ui/placement' +import type { Placement } from '../placement' import { Tooltip as BaseTooltip } from '@base-ui/react/tooltip' -import { cn } from '@langgenius/dify-ui/cn' -import { parsePlacement } from '@/app/components/base/ui/placement' +import { cn } from '../cn' +import { parsePlacement } from '../placement' + +export type { Placement } type TooltipContentVariant = 'default' | 'plain' diff --git a/packages/dify-ui/tailwind.config.ts b/packages/dify-ui/tailwind.config.ts new file mode 100644 index 0000000000..bcf1731775 --- /dev/null +++ b/packages/dify-ui/tailwind.config.ts @@ -0,0 +1,23 @@ +import type { Config } from 'tailwindcss' +import { getIconCollections, iconsPlugin } from '@egoist/tailwindcss-icons' +import difyUIPreset from './src/tailwind-preset' + +const config: Config = { + content: [ + './src/**/*.{js,ts,jsx,tsx,mdx}', + './.storybook/**/*.{js,ts,jsx,tsx,mdx}', + ], + presets: [difyUIPreset], + plugins: [ + iconsPlugin({ + collections: getIconCollections(['ri']), + extraProperties: { + width: '1rem', + height: '1rem', + display: 'block', + }, + }), + ], +} + +export default config diff --git a/packages/dify-ui/tests/setup.ts b/packages/dify-ui/tests/setup.ts new file mode 100644 index 0000000000..e1ea15af2b --- /dev/null +++ b/packages/dify-ui/tests/setup.ts @@ -0,0 +1,44 @@ +import { act, cleanup } from '@testing-library/react' +import '@testing-library/jest-dom/vitest' + +if (typeof Element !== 'undefined' && !Element.prototype.getAnimations) + Element.prototype.getAnimations = () => [] + +if (typeof document !== 'undefined' && !document.getAnimations) + document.getAnimations = () => [] + +if (typeof globalThis.ResizeObserver === 'undefined') { + globalThis.ResizeObserver = class { + observe() { + return undefined + } + + unobserve() { + return undefined + } + + disconnect() { + return undefined + } + } +} + +if (typeof globalThis.IntersectionObserver === 'undefined') { + globalThis.IntersectionObserver = class { + readonly root: Element | Document | null = null + readonly rootMargin: string = '' + readonly scrollMargin: string = '' + readonly thresholds: ReadonlyArray = [] + constructor(_callback: IntersectionObserverCallback, _options?: IntersectionObserverInit) { /* noop */ } + observe(_target: Element) { /* noop */ } + unobserve(_target: Element) { /* noop */ } + disconnect() { /* noop */ } + takeRecords(): IntersectionObserverEntry[] { return [] } + } +} + +afterEach(async () => { + await act(async () => { + cleanup() + }) +}) diff --git a/packages/dify-ui/tsconfig.json b/packages/dify-ui/tsconfig.json index b31c48ead6..678e1de897 100644 --- a/packages/dify-ui/tsconfig.json +++ b/packages/dify-ui/tsconfig.json @@ -1,14 +1,10 @@ { - "extends": "@dify/tsconfig/base.json", + "extends": "@dify/tsconfig/react.json", "compilerOptions": { - "rootDir": "src", - "declaration": true, - "declarationMap": true, - "noEmit": false, - "outDir": "dist", - "sourceMap": true, + "rootDir": ".", + "types": ["vitest/globals", "@testing-library/jest-dom"], "isolatedModules": true, "verbatimModuleSyntax": true }, - "include": ["src"] + "include": ["src", "tests", ".storybook", "tailwind.config.ts", "vite.config.ts"] } diff --git a/packages/dify-ui/vite.config.ts b/packages/dify-ui/vite.config.ts new file mode 100644 index 0000000000..a20b7fb35c --- /dev/null +++ b/packages/dify-ui/vite.config.ts @@ -0,0 +1,27 @@ +import react from '@vitejs/plugin-react' +import { defineConfig } from 'vite-plus' + +const isCI = !!process.env.CI + +export default defineConfig({ + plugins: [react()], + resolve: { + tsconfigPaths: true, + }, + test: { + environment: 'happy-dom', + globals: true, + setupFiles: ['./tests/setup.ts'], + coverage: { + provider: 'v8', + include: ['src/**/*.{ts,tsx}'], + exclude: [ + 'src/**/*.stories.{ts,tsx}', + 'src/**/__tests__/**', + 'src/themes/**', + 'src/styles/**', + ], + reporter: isCI ? ['json', 'json-summary'] : ['text', 'json', 'json-summary'], + }, + }, +}) diff --git a/packages/tsconfig/nextjs.json b/packages/tsconfig/nextjs.json index 81c6436a97..5beddd55ed 100644 --- a/packages/tsconfig/nextjs.json +++ b/packages/tsconfig/nextjs.json @@ -1,5 +1,5 @@ { - "extends": "./web.json", + "extends": "./react.json", "compilerOptions": { "plugins": [ { diff --git a/packages/tsconfig/package.json b/packages/tsconfig/package.json index 52cafc5bb3..7a5b20c1db 100644 --- a/packages/tsconfig/package.json +++ b/packages/tsconfig/package.json @@ -6,6 +6,6 @@ "./base.json": "./base.json", "./nextjs.json": "./nextjs.json", "./node.json": "./node.json", - "./web.json": "./web.json" + "./react.json": "./react.json" } } diff --git a/packages/tsconfig/web.json b/packages/tsconfig/react.json similarity index 100% rename from packages/tsconfig/web.json rename to packages/tsconfig/react.json diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 914bc342e2..1e0994d0a2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -135,6 +135,9 @@ catalogs: '@storybook/react': specifier: 10.3.5 version: 10.3.5 + '@storybook/react-vite': + specifier: 10.3.5 + version: 10.3.5 '@streamdown/math': specifier: 1.0.2 version: 1.0.2 @@ -632,15 +635,87 @@ importers: specifier: 'catalog:' version: 3.5.0 devDependencies: + '@base-ui/react': + specifier: 'catalog:' + version: 1.4.0(@date-fns/tz@1.4.1)(@types/react@19.2.14)(date-fns@4.1.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@chromatic-com/storybook': + specifier: 'catalog:' + version: 5.1.2(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)) '@dify/tsconfig': specifier: workspace:* version: link:../tsconfig + '@egoist/tailwindcss-icons': + specifier: 'catalog:' + version: 1.9.2(tailwindcss@4.2.2) + '@iconify-json/ri': + specifier: 'catalog:' + version: 1.2.10 + '@storybook/addon-docs': + specifier: 'catalog:' + version: 10.3.5(@types/react@19.2.14)(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(rollup@4.59.0)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(webpack@5.105.4(esbuild@0.27.2)) + '@storybook/addon-links': + specifier: 'catalog:' + version: 10.3.5(react@19.2.5)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)) + '@storybook/addon-themes': + specifier: 'catalog:' + version: 10.3.5(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)) + '@storybook/react-vite': + specifier: 'catalog:' + version: 10.3.5(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(rollup@4.59.0)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@6.0.2)(webpack@5.105.4(esbuild@0.27.2)) + '@tailwindcss/vite': + specifier: 'catalog:' + version: 4.2.2(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)) + '@testing-library/jest-dom': + specifier: 'catalog:' + version: 6.9.1 + '@testing-library/react': + specifier: 'catalog:' + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@testing-library/user-event': + specifier: 'catalog:' + version: 14.6.1(@testing-library/dom@10.4.1) + '@types/react': + specifier: 'catalog:' + version: 19.2.14 + '@types/react-dom': + specifier: 'catalog:' + version: 19.2.3(@types/react@19.2.14) + '@vitejs/plugin-react': + specifier: 'catalog:' + version: 6.0.1(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)) + '@vitest/coverage-v8': + specifier: 'catalog:' + version: 4.1.4(@voidzero-dev/vite-plus-test@0.1.18) + class-variance-authority: + specifier: 'catalog:' + version: 0.7.1 + happy-dom: + specifier: 'catalog:' + version: 20.9.0 + react: + specifier: 'catalog:' + version: 19.2.5 + react-dom: + specifier: 'catalog:' + version: 19.2.5(react@19.2.5) + storybook: + specifier: 'catalog:' + version: 10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) tailwindcss: specifier: 'catalog:' version: 4.2.2 typescript: specifier: 'catalog:' version: 6.0.2 + vite: + specifier: npm:@voidzero-dev/vite-plus-core@0.1.18 + version: '@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' + vite-plus: + specifier: 'catalog:' + version: 0.1.18(@types/node@25.6.0)(@vitest/coverage-v8@4.1.4)(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3) + vitest: + specifier: npm:@voidzero-dev/vite-plus-test@0.1.18 + version: '@voidzero-dev/vite-plus-test@0.1.18(@types/node@25.6.0)(@vitest/coverage-v8@4.1.4)(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' packages/iconify-collections: devDependencies: @@ -11202,6 +11277,23 @@ snapshots: - vite - webpack + '@storybook/addon-docs@10.3.5(@types/react@19.2.14)(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(rollup@4.59.0)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(webpack@5.105.4(esbuild@0.27.2))': + dependencies: + '@mdx-js/react': 3.1.1(@types/react@19.2.14)(react@19.2.5) + '@storybook/csf-plugin': 10.3.5(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(rollup@4.59.0)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(webpack@5.105.4(esbuild@0.27.2)) + '@storybook/icons': 2.0.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@storybook/react-dom-shim': 10.3.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + storybook: 10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + ts-dedent: 2.2.0 + transitivePeerDependencies: + - '@types/react' + - esbuild + - rollup + - vite + - webpack + '@storybook/addon-links@10.3.5(react@19.2.5)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))': dependencies: '@storybook/global': 5.0.0 @@ -11229,6 +11321,17 @@ snapshots: - rollup - webpack + '@storybook/builder-vite@10.3.5(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(rollup@4.59.0)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(webpack@5.105.4(esbuild@0.27.2))': + dependencies: + '@storybook/csf-plugin': 10.3.5(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(rollup@4.59.0)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(webpack@5.105.4(esbuild@0.27.2)) + storybook: 10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + ts-dedent: 2.2.0 + vite: '@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' + transitivePeerDependencies: + - esbuild + - rollup + - webpack + '@storybook/csf-plugin@10.3.5(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(rollup@4.59.0)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(webpack@5.105.4(esbuild@0.27.2)(uglify-js@3.19.3))': dependencies: storybook: 10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) @@ -11239,6 +11342,16 @@ snapshots: vite: '@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' webpack: 5.105.4(esbuild@0.27.2)(uglify-js@3.19.3) + '@storybook/csf-plugin@10.3.5(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(rollup@4.59.0)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(webpack@5.105.4(esbuild@0.27.2))': + dependencies: + storybook: 10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + unplugin: 2.3.11 + optionalDependencies: + esbuild: 0.27.2 + rollup: 4.59.0 + vite: '@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' + webpack: 5.105.4(esbuild@0.27.2) + '@storybook/global@5.0.0': {} '@storybook/icons@2.0.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': @@ -11296,6 +11409,28 @@ snapshots: - typescript - webpack + '@storybook/react-vite@10.3.5(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(rollup@4.59.0)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@6.0.2)(webpack@5.105.4(esbuild@0.27.2))': + dependencies: + '@joshwooding/vite-plugin-react-docgen-typescript': 0.7.0(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(typescript@6.0.2) + '@rollup/pluginutils': 5.3.0(rollup@4.59.0) + '@storybook/builder-vite': 10.3.5(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(rollup@4.59.0)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(webpack@5.105.4(esbuild@0.27.2)) + '@storybook/react': 10.3.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@6.0.2) + empathic: 2.0.0 + magic-string: 0.30.21 + react: 19.2.5 + react-docgen: 8.0.3 + react-dom: 19.2.5(react@19.2.5) + resolve: 1.22.11 + storybook: 10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + tsconfig-paths: 4.2.0 + vite: '@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' + transitivePeerDependencies: + - esbuild + - rollup + - supports-color + - typescript + - webpack + '@storybook/react@10.3.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@6.0.2)': dependencies: '@storybook/global': 5.0.0 @@ -16589,6 +16724,17 @@ snapshots: esbuild: 0.27.2 uglify-js: 3.19.3 + terser-webpack-plugin@5.4.0(esbuild@0.27.2)(webpack@5.105.4(esbuild@0.27.2)): + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + jest-worker: 27.5.1 + schema-utils: 4.3.3 + terser: 5.46.1 + webpack: 5.105.4(esbuild@0.27.2) + optionalDependencies: + esbuild: 0.27.2 + optional: true + terser@5.46.1: dependencies: '@jridgewell/source-map': 0.3.11 @@ -17118,6 +17264,39 @@ snapshots: webpack-virtual-modules@0.6.2: {} + webpack@5.105.4(esbuild@0.27.2): + dependencies: + '@types/eslint-scope': 3.7.7 + '@types/estree': 1.0.8 + '@types/json-schema': 7.0.15 + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/wasm-edit': 1.14.1 + '@webassemblyjs/wasm-parser': 1.14.1 + acorn: 8.16.0 + acorn-import-phases: 1.0.4(acorn@8.16.0) + browserslist: 4.28.1 + chrome-trace-event: 1.0.4 + enhanced-resolve: 5.20.1 + es-module-lexer: 2.0.0 + eslint-scope: 5.1.1 + events: 3.3.0 + glob-to-regexp: 0.4.1 + graceful-fs: 4.2.11 + json-parse-even-better-errors: 2.3.1 + loader-runner: 4.3.1 + mime-types: 2.1.35 + neo-async: 2.6.2 + schema-utils: 4.3.3 + tapable: 2.3.2 + terser-webpack-plugin: 5.4.0(esbuild@0.27.2)(webpack@5.105.4(esbuild@0.27.2)) + watchpack: 2.5.1 + webpack-sources: 3.3.4 + transitivePeerDependencies: + - '@swc/core' + - esbuild + - uglify-js + optional: true + webpack@5.105.4(esbuild@0.27.2)(uglify-js@3.19.3): dependencies: '@types/eslint-scope': 3.7.7 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 0bd1303fb3..12fea26d68 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -55,6 +55,7 @@ catalog: '@storybook/addon-themes': 10.3.5 '@storybook/nextjs-vite': 10.3.5 '@storybook/react': 10.3.5 + '@storybook/react-vite': 10.3.5 '@streamdown/math': 1.0.2 '@svgdotjs/svg.js': 3.2.5 '@t3-oss/env-nextjs': 0.13.11 diff --git a/web/.storybook/preview.tsx b/web/.storybook/preview.tsx index a9144e7128..92b5baab0d 100644 --- a/web/.storybook/preview.tsx +++ b/web/.storybook/preview.tsx @@ -1,8 +1,8 @@ import type { Preview } from '@storybook/react' import type { Resource } from 'i18next' +import { ToastHost } from '@langgenius/dify-ui/toast' import { withThemeByDataAttribute } from '@storybook/addon-themes' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { ToastHost } from '../app/components/base/ui/toast' import { I18nClientProvider as I18N } from '../app/components/provider/i18n' import commonEnUS from '../i18n/en-US/common.json' diff --git a/web/__tests__/app/app-publisher-flow.test.tsx b/web/__tests__/app/app-publisher-flow.test.tsx index 4f24fc310c..9c09acf6a1 100644 --- a/web/__tests__/app/app-publisher-flow.test.tsx +++ b/web/__tests__/app/app-publisher-flow.test.tsx @@ -106,7 +106,7 @@ vi.mock('@/service/apps', () => ({ fetchAppDetailDirect: (...args: unknown[]) => mockFetchAppDetailDirect(...args), })) -vi.mock('@/app/components/base/ui/toast', () => ({ +vi.mock('@langgenius/dify-ui/toast', () => ({ toast: { error: (...args: unknown[]) => mockToastError(...args), }, diff --git a/web/__tests__/apps/app-card-operations-flow.test.tsx b/web/__tests__/apps/app-card-operations-flow.test.tsx index 4b1c05e7ae..8c3219794d 100644 --- a/web/__tests__/apps/app-card-operations-flow.test.tsx +++ b/web/__tests__/apps/app-card-operations-flow.test.tsx @@ -31,7 +31,7 @@ const toastMocks = vi.hoisted(() => ({ })) const mockRouterPush = vi.fn() -vi.mock('@/app/components/base/ui/toast', () => ({ +vi.mock('@langgenius/dify-ui/toast', () => ({ toast: { success: (message: string, options?: Record) => toastMocks.mockNotify({ type: 'success', message, ...options }), error: (message: string, options?: Record) => toastMocks.mockNotify({ type: 'error', message, ...options }), diff --git a/web/__tests__/billing/cloud-plan-payment-flow.test.tsx b/web/__tests__/billing/cloud-plan-payment-flow.test.tsx index 0c1efbe1af..de145a73e6 100644 --- a/web/__tests__/billing/cloud-plan-payment-flow.test.tsx +++ b/web/__tests__/billing/cloud-plan-payment-flow.test.tsx @@ -8,10 +8,10 @@ * and workspace manager permission enforcement. */ import type { BasicPlan } from '@/app/components/billing/type' +import { toast, ToastHost } from '@langgenius/dify-ui/toast' import { cleanup, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import * as React from 'react' -import { toast, ToastHost } from '@/app/components/base/ui/toast' import { ALL_PLANS } from '@/app/components/billing/config' import { PlanRange } from '@/app/components/billing/pricing/plan-switcher/plan-range-switcher' import CloudPlanItem from '@/app/components/billing/pricing/plans/cloud-plan-item' diff --git a/web/__tests__/billing/self-hosted-plan-flow.test.tsx b/web/__tests__/billing/self-hosted-plan-flow.test.tsx index a3386d0092..f2b5cd3531 100644 --- a/web/__tests__/billing/self-hosted-plan-flow.test.tsx +++ b/web/__tests__/billing/self-hosted-plan-flow.test.tsx @@ -1,3 +1,4 @@ +import { toast, ToastHost } from '@langgenius/dify-ui/toast' /** * Integration test: Self-Hosted Plan Flow * @@ -10,7 +11,6 @@ import { cleanup, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import * as React from 'react' -import { toast, ToastHost } from '@/app/components/base/ui/toast' import { contactSalesUrl, getStartedWithCommunityUrl, getWithPremiumUrl } from '@/app/components/billing/config' import SelfHostedPlanItem from '@/app/components/billing/pricing/plans/self-hosted-plan-item' import { SelfHostedPlan } from '@/app/components/billing/type' diff --git a/web/__tests__/datasets/create-dataset-flow.test.tsx b/web/__tests__/datasets/create-dataset-flow.test.tsx index 34d64d8c43..c8504a10c7 100644 --- a/web/__tests__/datasets/create-dataset-flow.test.tsx +++ b/web/__tests__/datasets/create-dataset-flow.test.tsx @@ -33,7 +33,7 @@ vi.mock('@/service/knowledge/use-dataset', () => ({ useInvalidDatasetList: () => vi.fn(), })) -vi.mock('@/app/components/base/ui/toast', () => ({ +vi.mock('@langgenius/dify-ui/toast', () => ({ default: { notify: vi.fn() }, toast: { success: vi.fn(), diff --git a/web/__tests__/datasets/dataset-settings-flow.test.tsx b/web/__tests__/datasets/dataset-settings-flow.test.tsx index 7c53401b15..eaafcafe62 100644 --- a/web/__tests__/datasets/dataset-settings-flow.test.tsx +++ b/web/__tests__/datasets/dataset-settings-flow.test.tsx @@ -59,7 +59,7 @@ vi.mock('@/app/components/datasets/common/check-rerank-model', () => ({ isReRankModelSelected: () => true, })) -vi.mock('@/app/components/base/ui/toast', () => ({ +vi.mock('@langgenius/dify-ui/toast', () => ({ toast: { error: mockToastError, success: vi.fn(), @@ -318,7 +318,7 @@ describe('Dataset Settings Flow - Cross-Module Configuration Cascade', () => { describe('Form Submission Validation → All Fields Together', () => { it('should reject empty name on save', async () => { - const { toast } = await import('@/app/components/base/ui/toast') + const { toast } = await import('@langgenius/dify-ui/toast') const { result } = renderHook(() => useFormState()) act(() => { diff --git a/web/__tests__/explore/sidebar-lifecycle-flow.test.tsx b/web/__tests__/explore/sidebar-lifecycle-flow.test.tsx index 64dd5321ac..35c8175a36 100644 --- a/web/__tests__/explore/sidebar-lifecycle-flow.test.tsx +++ b/web/__tests__/explore/sidebar-lifecycle-flow.test.tsx @@ -53,8 +53,8 @@ vi.mock('@/service/use-explore', () => ({ }), })) -vi.mock('@/app/components/base/ui/toast', async (importOriginal) => { - const actual = await importOriginal() +vi.mock('@langgenius/dify-ui/toast', async (importOriginal) => { + const actual = await importOriginal() return { ...actual, toast: { diff --git a/web/__tests__/plugins/plugin-install-flow.test.ts b/web/__tests__/plugins/plugin-install-flow.test.ts index dd5a18b724..d975eb7167 100644 --- a/web/__tests__/plugins/plugin-install-flow.test.ts +++ b/web/__tests__/plugins/plugin-install-flow.test.ts @@ -10,7 +10,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { checkForUpdates, fetchReleases, handleUpload } from '@/app/components/plugins/install-plugin/hooks' const mockToastNotify = vi.fn() -vi.mock('@/app/components/base/ui/toast', () => ({ +vi.mock('@langgenius/dify-ui/toast', () => ({ toast: Object.assign((message: string, options?: { type?: string }) => mockToastNotify({ type: options?.type, message }), { success: (message: string) => mockToastNotify({ type: 'success', message }), error: (message: string) => mockToastNotify({ type: 'error', message }), diff --git a/web/__tests__/rag-pipeline/dsl-export-import-flow.test.ts b/web/__tests__/rag-pipeline/dsl-export-import-flow.test.ts index cdf7aba4f6..cc97065d8f 100644 --- a/web/__tests__/rag-pipeline/dsl-export-import-flow.test.ts +++ b/web/__tests__/rag-pipeline/dsl-export-import-flow.test.ts @@ -20,7 +20,7 @@ const mockToast = { promise: vi.fn(), } -vi.mock('@/app/components/base/ui/toast', () => ({ +vi.mock('@langgenius/dify-ui/toast', () => ({ toast: mockToast, })) const mockEventEmitter = { emit: vi.fn() } diff --git a/web/__tests__/tools/tool-provider-detail-flow.test.tsx b/web/__tests__/tools/tool-provider-detail-flow.test.tsx index c52871c946..9cf4772152 100644 --- a/web/__tests__/tools/tool-provider-detail-flow.test.tsx +++ b/web/__tests__/tools/tool-provider-detail-flow.test.tsx @@ -133,7 +133,7 @@ vi.mock('@/app/components/base/drawer', () => ({ ), })) -vi.mock('@/app/components/base/ui/toast', () => ({ +vi.mock('@langgenius/dify-ui/toast', () => ({ default: { notify: vi.fn() }, toast: { success: vi.fn(), diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/card-view.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/card-view.tsx index 289e50c257..8ef0b65e0f 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/card-view.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/card-view.tsx @@ -5,6 +5,7 @@ import type { BlockEnum } from '@/app/components/workflow/types' import type { UpdateAppSiteCodeResponse } from '@/models/app' import type { App } from '@/types/app' import type { I18nKeysByPrefix } from '@/types/i18n' +import { toast } from '@langgenius/dify-ui/toast' import * as React from 'react' import { useCallback, useEffect, useMemo } from 'react' import { useTranslation } from 'react-i18next' @@ -12,7 +13,6 @@ import AppCard from '@/app/components/app/overview/app-card' import TriggerCard from '@/app/components/app/overview/trigger-card' import { useStore as useAppStore } from '@/app/components/app/store' import Loading from '@/app/components/base/loading' -import { toast } from '@/app/components/base/ui/toast' import MCPServiceCard from '@/app/components/tools/mcp/mcp-service-card' import { collaborationManager } from '@/app/components/workflow/collaboration/core/collaboration-manager' import { webSocketClient } from '@/app/components/workflow/collaboration/core/websocket-manager' diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/config-popup.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/config-popup.tsx index 3efd58ac40..471ab86e12 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/config-popup.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/config-popup.tsx @@ -2,12 +2,12 @@ import type { FC, JSX } from 'react' import type { AliyunConfig, ArizeConfig, DatabricksConfig, LangFuseConfig, LangSmithConfig, MLflowConfig, OpikConfig, PhoenixConfig, TencentConfig, WeaveConfig } from './type' import { cn } from '@langgenius/dify-ui/cn' +import { Switch } from '@langgenius/dify-ui/switch' import { useBoolean } from 'ahooks' import * as React from 'react' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import Divider from '@/app/components/base/divider' -import Switch from '@/app/components/base/switch' import Tooltip from '@/app/components/base/tooltip' import Indicator from '@/app/components/header/indicator' import ProviderConfigModal from './provider-config-modal' diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/panel.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/panel.tsx index 9c42f85825..35fafb2a79 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/panel.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/panel.tsx @@ -3,6 +3,7 @@ import type { FC } from 'react' import type { AliyunConfig, ArizeConfig, DatabricksConfig, LangFuseConfig, LangSmithConfig, MLflowConfig, OpikConfig, PhoenixConfig, TencentConfig, WeaveConfig } from './type' import type { TracingStatus } from '@/models/app' import { cn } from '@langgenius/dify-ui/cn' +import { toast } from '@langgenius/dify-ui/toast' import { RiArrowDownDoubleLine, RiEqualizer2Line, @@ -14,7 +15,6 @@ import { useTranslation } from 'react-i18next' import Divider from '@/app/components/base/divider' import { AliyunIcon, ArizeIcon, DatabricksIcon, LangfuseIcon, LangsmithIcon, MlflowIcon, OpikIcon, PhoenixIcon, TencentIcon, WeaveIcon } from '@/app/components/base/icons/src/public/tracing' import Loading from '@/app/components/base/loading' -import { toast } from '@/app/components/base/ui/toast' import Indicator from '@/app/components/header/indicator' import { useAppContext } from '@/context/app-context' import { usePathname } from '@/next/navigation' diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/provider-config-modal.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/provider-config-modal.tsx index 6ff8725f63..4f2497ad71 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/provider-config-modal.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/provider-config-modal.tsx @@ -1,6 +1,17 @@ 'use client' import type { FC } from 'react' import type { AliyunConfig, ArizeConfig, DatabricksConfig, LangFuseConfig, LangSmithConfig, MLflowConfig, OpikConfig, PhoenixConfig, TencentConfig, WeaveConfig } from './type' +import { + AlertDialog, + AlertDialogActions, + AlertDialogCancelButton, + AlertDialogConfirmButton, + AlertDialogContent, + AlertDialogDescription, + AlertDialogTitle, +} from '@langgenius/dify-ui/alert-dialog' +import { Button } from '@langgenius/dify-ui/button' +import { toast } from '@langgenius/dify-ui/toast' import { useBoolean } from 'ahooks' import * as React from 'react' import { useCallback, useState } from 'react' @@ -12,17 +23,6 @@ import { PortalToFollowElem, PortalToFollowElemContent, } from '@/app/components/base/portal-to-follow-elem' -import { - AlertDialog, - AlertDialogActions, - AlertDialogCancelButton, - AlertDialogConfirmButton, - AlertDialogContent, - AlertDialogDescription, - AlertDialogTitle, -} from '@/app/components/base/ui/alert-dialog' -import { Button } from '@/app/components/base/ui/button' -import { toast } from '@/app/components/base/ui/toast' import { addTracingConfig, removeTracingConfig, updateTracingConfig } from '@/service/apps' import { docURL } from './config' import Field from './field' diff --git a/web/app/(humanInputLayout)/form/[token]/form.tsx b/web/app/(humanInputLayout)/form/[token]/form.tsx index be30585101..76dcd24293 100644 --- a/web/app/(humanInputLayout)/form/[token]/form.tsx +++ b/web/app/(humanInputLayout)/form/[token]/form.tsx @@ -1,8 +1,9 @@ 'use client' -import type { ButtonProps } from '@/app/components/base/ui/button' +import type { ButtonProps } from '@langgenius/dify-ui/button' import type { FormInputItem, UserAction } from '@/app/components/workflow/nodes/human-input/types' import type { SiteInfo } from '@/models/share' import type { HumanInputFormError } from '@/service/use-share' +import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { RiCheckboxCircleFill, @@ -19,7 +20,6 @@ import ExpirationTime from '@/app/components/base/chat/chat/answer/human-input-c import { getButtonStyle } from '@/app/components/base/chat/chat/answer/human-input-content/utils' import Loading from '@/app/components/base/loading' import DifyLogo from '@/app/components/base/logo/dify-logo' -import { Button } from '@/app/components/base/ui/button' import useDocumentTitle from '@/hooks/use-document-title' import { useParams } from '@/next/navigation' import { useGetHumanInputForm, useSubmitHumanInputForm } from '@/service/use-share' diff --git a/web/app/(shareLayout)/webapp-reset-password/check-code/page.tsx b/web/app/(shareLayout)/webapp-reset-password/check-code/page.tsx index d19e5a7d2d..d5b3237b12 100644 --- a/web/app/(shareLayout)/webapp-reset-password/check-code/page.tsx +++ b/web/app/(shareLayout)/webapp-reset-password/check-code/page.tsx @@ -1,10 +1,10 @@ 'use client' +import { Button } from '@langgenius/dify-ui/button' +import { toast } from '@langgenius/dify-ui/toast' import { RiArrowLeftLine, RiMailSendFill } from '@remixicon/react' import { useState } from 'react' import { useTranslation } from 'react-i18next' import Input from '@/app/components/base/input' -import { Button } from '@/app/components/base/ui/button' -import { toast } from '@/app/components/base/ui/toast' import Countdown from '@/app/components/signin/countdown' import { useLocale } from '@/context/i18n' diff --git a/web/app/(shareLayout)/webapp-reset-password/page.tsx b/web/app/(shareLayout)/webapp-reset-password/page.tsx index cb6ece219c..2174ac0e8e 100644 --- a/web/app/(shareLayout)/webapp-reset-password/page.tsx +++ b/web/app/(shareLayout)/webapp-reset-password/page.tsx @@ -1,11 +1,11 @@ 'use client' +import { Button } from '@langgenius/dify-ui/button' +import { toast } from '@langgenius/dify-ui/toast' import { RiArrowLeftLine, RiLockPasswordLine } from '@remixicon/react' import { noop } from 'es-toolkit/function' import { useState } from 'react' import { useTranslation } from 'react-i18next' import Input from '@/app/components/base/input' -import { Button } from '@/app/components/base/ui/button' -import { toast } from '@/app/components/base/ui/toast' import { COUNT_DOWN_KEY, COUNT_DOWN_TIME_MS } from '@/app/components/signin/countdown' import { emailRegex } from '@/config' import { useLocale } from '@/context/i18n' diff --git a/web/app/(shareLayout)/webapp-reset-password/set-password/page.tsx b/web/app/(shareLayout)/webapp-reset-password/set-password/page.tsx index df102e609a..9a290f8789 100644 --- a/web/app/(shareLayout)/webapp-reset-password/set-password/page.tsx +++ b/web/app/(shareLayout)/webapp-reset-password/set-password/page.tsx @@ -1,12 +1,12 @@ 'use client' +import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' +import { toast } from '@langgenius/dify-ui/toast' import { RiCheckboxCircleFill } from '@remixicon/react' import { useCountDown } from 'ahooks' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import Input from '@/app/components/base/input' -import { Button } from '@/app/components/base/ui/button' -import { toast } from '@/app/components/base/ui/toast' import { validPassword } from '@/config' import { useRouter, useSearchParams } from '@/next/navigation' import { changeWebAppPasswordWithToken } from '@/service/common' diff --git a/web/app/(shareLayout)/webapp-signin/check-code/page.tsx b/web/app/(shareLayout)/webapp-signin/check-code/page.tsx index b17fff4e52..c8d3351ded 100644 --- a/web/app/(shareLayout)/webapp-signin/check-code/page.tsx +++ b/web/app/(shareLayout)/webapp-signin/check-code/page.tsx @@ -1,11 +1,11 @@ 'use client' import type { FormEvent } from 'react' +import { Button } from '@langgenius/dify-ui/button' +import { toast } from '@langgenius/dify-ui/toast' import { RiArrowLeftLine, RiMailSendFill } from '@remixicon/react' import { useCallback, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import Input from '@/app/components/base/input' -import { Button } from '@/app/components/base/ui/button' -import { toast } from '@/app/components/base/ui/toast' import Countdown from '@/app/components/signin/countdown' import { useLocale } from '@/context/i18n' import { useWebAppStore } from '@/context/web-app-context' diff --git a/web/app/(shareLayout)/webapp-signin/components/external-member-sso-auth.tsx b/web/app/(shareLayout)/webapp-signin/components/external-member-sso-auth.tsx index 9b4a369908..fe6b157c1e 100644 --- a/web/app/(shareLayout)/webapp-signin/components/external-member-sso-auth.tsx +++ b/web/app/(shareLayout)/webapp-signin/components/external-member-sso-auth.tsx @@ -1,9 +1,9 @@ 'use client' +import { toast } from '@langgenius/dify-ui/toast' import * as React from 'react' import { useCallback, useEffect } from 'react' import AppUnavailable from '@/app/components/base/app-unavailable' import Loading from '@/app/components/base/loading' -import { toast } from '@/app/components/base/ui/toast' import { useGlobalPublicStore } from '@/context/global-public-context' import { useRouter, useSearchParams } from '@/next/navigation' import { fetchWebOAuth2SSOUrl, fetchWebOIDCSSOUrl, fetchWebSAMLSSOUrl } from '@/service/share' diff --git a/web/app/(shareLayout)/webapp-signin/components/mail-and-code-auth.tsx b/web/app/(shareLayout)/webapp-signin/components/mail-and-code-auth.tsx index f600dba8b2..2d2fde1808 100644 --- a/web/app/(shareLayout)/webapp-signin/components/mail-and-code-auth.tsx +++ b/web/app/(shareLayout)/webapp-signin/components/mail-and-code-auth.tsx @@ -1,9 +1,9 @@ +import { Button } from '@langgenius/dify-ui/button' +import { toast } from '@langgenius/dify-ui/toast' import { noop } from 'es-toolkit/function' import { useState } from 'react' import { useTranslation } from 'react-i18next' import Input from '@/app/components/base/input' -import { Button } from '@/app/components/base/ui/button' -import { toast } from '@/app/components/base/ui/toast' import { COUNT_DOWN_KEY, COUNT_DOWN_TIME_MS } from '@/app/components/signin/countdown' import { emailRegex } from '@/config' import { useLocale } from '@/context/i18n' diff --git a/web/app/(shareLayout)/webapp-signin/components/mail-and-password-auth.tsx b/web/app/(shareLayout)/webapp-signin/components/mail-and-password-auth.tsx index 7fe5363927..79607a77df 100644 --- a/web/app/(shareLayout)/webapp-signin/components/mail-and-password-auth.tsx +++ b/web/app/(shareLayout)/webapp-signin/components/mail-and-password-auth.tsx @@ -1,10 +1,10 @@ 'use client' +import { Button } from '@langgenius/dify-ui/button' +import { toast } from '@langgenius/dify-ui/toast' import { noop } from 'es-toolkit/function' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import Input from '@/app/components/base/input' -import { Button } from '@/app/components/base/ui/button' -import { toast } from '@/app/components/base/ui/toast' import { emailRegex } from '@/config' import { useLocale } from '@/context/i18n' import { useWebAppStore } from '@/context/web-app-context' diff --git a/web/app/(shareLayout)/webapp-signin/components/sso-auth.tsx b/web/app/(shareLayout)/webapp-signin/components/sso-auth.tsx index d8759cc1d6..53ff80ced5 100644 --- a/web/app/(shareLayout)/webapp-signin/components/sso-auth.tsx +++ b/web/app/(shareLayout)/webapp-signin/components/sso-auth.tsx @@ -1,10 +1,10 @@ 'use client' import type { FC } from 'react' +import { Button } from '@langgenius/dify-ui/button' +import { toast } from '@langgenius/dify-ui/toast' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import { Lock01 } from '@/app/components/base/icons/src/vender/solid/security' -import { Button } from '@/app/components/base/ui/button' -import { toast } from '@/app/components/base/ui/toast' import { useRouter, useSearchParams } from '@/next/navigation' import { fetchMembersOAuth2SSOUrl, fetchMembersOIDCSSOUrl, fetchMembersSAMLSSOUrl } from '@/service/share' import { SSOProtocol } from '@/types/feature' diff --git a/web/app/account/(commonLayout)/account-page/AvatarWithEdit.tsx b/web/app/account/(commonLayout)/account-page/AvatarWithEdit.tsx index 05048008b4..429eece383 100644 --- a/web/app/account/(commonLayout)/account-page/AvatarWithEdit.tsx +++ b/web/app/account/(commonLayout)/account-page/AvatarWithEdit.tsx @@ -1,9 +1,13 @@ 'use client' +import type { AvatarProps } from '@langgenius/dify-ui/avatar' import type { Area } from 'react-easy-crop' import type { OnImageInput } from '@/app/components/base/app-icon-picker/ImageInput' -import type { AvatarProps } from '@/app/components/base/ui/avatar' import type { ImageFile } from '@/types/app' +import { Avatar } from '@langgenius/dify-ui/avatar' +import { Button } from '@langgenius/dify-ui/button' +import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog' +import { toast } from '@langgenius/dify-ui/toast' import { RiDeleteBin5Line, RiPencilLine } from '@remixicon/react' import * as React from 'react' import { useCallback, useState } from 'react' @@ -12,10 +16,6 @@ import ImageInput from '@/app/components/base/app-icon-picker/ImageInput' import getCroppedImg from '@/app/components/base/app-icon-picker/utils' import Divider from '@/app/components/base/divider' import { useLocalFileUploader } from '@/app/components/base/image-uploader/hooks' -import { Avatar } from '@/app/components/base/ui/avatar' -import { Button } from '@/app/components/base/ui/button' -import { Dialog, DialogContent } from '@/app/components/base/ui/dialog' -import { toast } from '@/app/components/base/ui/toast' import { DISABLE_UPLOAD_IMAGE_AS_ICON } from '@/config' import { updateUserProfile } from '@/service/common' diff --git a/web/app/account/(commonLayout)/account-page/email-change-modal.tsx b/web/app/account/(commonLayout)/account-page/email-change-modal.tsx index 1b91c801bd..f3bb71c2d2 100644 --- a/web/app/account/(commonLayout)/account-page/email-change-modal.tsx +++ b/web/app/account/(commonLayout)/account-page/email-change-modal.tsx @@ -1,12 +1,12 @@ import type { ResponseError } from '@/service/fetch' +import { Button } from '@langgenius/dify-ui/button' +import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog' +import { toast } from '@langgenius/dify-ui/toast' import { RiCloseLine } from '@remixicon/react' import * as React from 'react' import { useState } from 'react' import { Trans, useTranslation } from 'react-i18next' import Input from '@/app/components/base/input' -import { Button } from '@/app/components/base/ui/button' -import { Dialog, DialogContent } from '@/app/components/base/ui/dialog' -import { toast } from '@/app/components/base/ui/toast' import { useRouter } from '@/next/navigation' import { checkEmailExisted, diff --git a/web/app/account/(commonLayout)/account-page/index.tsx b/web/app/account/(commonLayout)/account-page/index.tsx index 8ad1b45467..a26fa942db 100644 --- a/web/app/account/(commonLayout)/account-page/index.tsx +++ b/web/app/account/(commonLayout)/account-page/index.tsx @@ -1,6 +1,9 @@ 'use client' import type { IItem } from '@/app/components/header/account-setting/collapse' import type { App } from '@/types/app' +import { Button } from '@langgenius/dify-ui/button' +import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog' +import { toast } from '@langgenius/dify-ui/toast' import { RiGraduationCapFill, } from '@remixicon/react' @@ -10,9 +13,6 @@ import { useTranslation } from 'react-i18next' import AppIcon from '@/app/components/base/app-icon' import Input from '@/app/components/base/input' import PremiumBadge from '@/app/components/base/premium-badge' -import { Button } from '@/app/components/base/ui/button' -import { Dialog, DialogContent } from '@/app/components/base/ui/dialog' -import { toast } from '@/app/components/base/ui/toast' import Collapse from '@/app/components/header/account-setting/collapse' import { IS_CE_EDITION, validPassword } from '@/config' import { useGlobalPublicStore } from '@/context/global-public-context' diff --git a/web/app/account/(commonLayout)/avatar.tsx b/web/app/account/(commonLayout)/avatar.tsx index 538e09b0fd..0893b130c4 100644 --- a/web/app/account/(commonLayout)/avatar.tsx +++ b/web/app/account/(commonLayout)/avatar.tsx @@ -1,5 +1,6 @@ 'use client' import { Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/react' +import { Avatar } from '@langgenius/dify-ui/avatar' import { RiGraduationCapFill, } from '@remixicon/react' @@ -8,7 +9,6 @@ import { useTranslation } from 'react-i18next' import { resetUser } from '@/app/components/base/amplitude/utils' import { LogOut01 } from '@/app/components/base/icons/src/vender/line/general' import PremiumBadge from '@/app/components/base/premium-badge' -import { Avatar } from '@/app/components/base/ui/avatar' import { useProviderContext } from '@/context/provider-context' import { useRouter } from '@/next/navigation' import { useLogout, useUserProfile } from '@/service/use-common' diff --git a/web/app/account/(commonLayout)/delete-account/components/check-email.tsx b/web/app/account/(commonLayout)/delete-account/components/check-email.tsx index 3c7b7112f3..7e3351a10a 100644 --- a/web/app/account/(commonLayout)/delete-account/components/check-email.tsx +++ b/web/app/account/(commonLayout)/delete-account/components/check-email.tsx @@ -1,8 +1,8 @@ 'use client' +import { Button } from '@langgenius/dify-ui/button' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import Input from '@/app/components/base/input' -import { Button } from '@/app/components/base/ui/button' import { useAppContext } from '@/context/app-context' import Link from '@/next/link' import { useSendDeleteAccountEmail } from '../state' diff --git a/web/app/account/(commonLayout)/delete-account/components/feed-back.tsx b/web/app/account/(commonLayout)/delete-account/components/feed-back.tsx index 9690bf8ed1..ee1e72e6e7 100644 --- a/web/app/account/(commonLayout)/delete-account/components/feed-back.tsx +++ b/web/app/account/(commonLayout)/delete-account/components/feed-back.tsx @@ -1,10 +1,10 @@ 'use client' +import { Button } from '@langgenius/dify-ui/button' +import { toast } from '@langgenius/dify-ui/toast' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import CustomDialog from '@/app/components/base/dialog' import Textarea from '@/app/components/base/textarea' -import { Button } from '@/app/components/base/ui/button' -import { toast } from '@/app/components/base/ui/toast' import { useAppContext } from '@/context/app-context' import { useRouter } from '@/next/navigation' import { useLogout } from '@/service/use-common' diff --git a/web/app/account/(commonLayout)/delete-account/components/verify-email.tsx b/web/app/account/(commonLayout)/delete-account/components/verify-email.tsx index 01e19e51b1..91c0d7737d 100644 --- a/web/app/account/(commonLayout)/delete-account/components/verify-email.tsx +++ b/web/app/account/(commonLayout)/delete-account/components/verify-email.tsx @@ -1,8 +1,8 @@ 'use client' +import { Button } from '@langgenius/dify-ui/button' import { useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import Input from '@/app/components/base/input' -import { Button } from '@/app/components/base/ui/button' import Countdown from '@/app/components/signin/countdown' import Link from '@/next/link' import { useAccountDeleteStore, useConfirmDeleteAccount, useSendDeleteAccountEmail } from '../state' diff --git a/web/app/account/(commonLayout)/header.tsx b/web/app/account/(commonLayout)/header.tsx index 688a1d7729..f0912d45d5 100644 --- a/web/app/account/(commonLayout)/header.tsx +++ b/web/app/account/(commonLayout)/header.tsx @@ -1,9 +1,9 @@ 'use client' +import { Button } from '@langgenius/dify-ui/button' import { RiArrowRightUpLine, RiRobot2Line } from '@remixicon/react' import { useCallback } from 'react' import { useTranslation } from 'react-i18next' import DifyLogo from '@/app/components/base/logo/dify-logo' -import { Button } from '@/app/components/base/ui/button' import { useGlobalPublicStore } from '@/context/global-public-context' import { useRouter } from '@/next/navigation' import Avatar from './avatar' diff --git a/web/app/account/oauth/authorize/page.tsx b/web/app/account/oauth/authorize/page.tsx index f370efcd3e..cd035ce16f 100644 --- a/web/app/account/oauth/authorize/page.tsx +++ b/web/app/account/oauth/authorize/page.tsx @@ -1,5 +1,8 @@ 'use client' +import { Avatar } from '@langgenius/dify-ui/avatar' +import { Button } from '@langgenius/dify-ui/button' +import { toast } from '@langgenius/dify-ui/toast' import { RiAccountCircleLine, RiGlobalLine, @@ -11,9 +14,6 @@ import * as React from 'react' import { useEffect, useRef } from 'react' import { useTranslation } from 'react-i18next' import Loading from '@/app/components/base/loading' -import { Avatar } from '@/app/components/base/ui/avatar' -import { Button } from '@/app/components/base/ui/button' -import { toast } from '@/app/components/base/ui/toast' import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks' import { setPostLoginRedirect } from '@/app/signin/utils/post-login-redirect' import { useRouter, useSearchParams } from '@/next/navigation' diff --git a/web/app/activate/activateForm.tsx b/web/app/activate/activateForm.tsx index 9b0a5f5b21..5fafa8eca1 100644 --- a/web/app/activate/activateForm.tsx +++ b/web/app/activate/activateForm.tsx @@ -1,9 +1,9 @@ 'use client' +import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { useEffect } from 'react' import { useTranslation } from 'react-i18next' import Loading from '@/app/components/base/loading' -import { Button } from '@/app/components/base/ui/button' import useDocumentTitle from '@/hooks/use-document-title' import { useRouter, useSearchParams } from '@/next/navigation' diff --git a/web/app/components/app-sidebar/__tests__/app-sidebar-dropdown.spec.tsx b/web/app/components/app-sidebar/__tests__/app-sidebar-dropdown.spec.tsx index 5e18bbc343..5557118c78 100644 --- a/web/app/components/app-sidebar/__tests__/app-sidebar-dropdown.spec.tsx +++ b/web/app/components/app-sidebar/__tests__/app-sidebar-dropdown.spec.tsx @@ -19,7 +19,7 @@ vi.mock('@/context/app-context', () => ({ }), })) -vi.mock('@/app/components/base/ui/dropdown-menu', () => { +vi.mock('@langgenius/dify-ui/dropdown-menu', () => { const DropdownMenuContext = React.createContext<{ isOpen: boolean, setOpen: (open: boolean) => void } | null>(null) const useDropdownMenuContext = () => { diff --git a/web/app/components/app-sidebar/__tests__/dataset-sidebar-dropdown.spec.tsx b/web/app/components/app-sidebar/__tests__/dataset-sidebar-dropdown.spec.tsx index 5060987cda..5c45df470a 100644 --- a/web/app/components/app-sidebar/__tests__/dataset-sidebar-dropdown.spec.tsx +++ b/web/app/components/app-sidebar/__tests__/dataset-sidebar-dropdown.spec.tsx @@ -21,7 +21,7 @@ vi.mock('@/hooks/use-knowledge', () => ({ }), })) -vi.mock('@/app/components/base/ui/dropdown-menu', () => { +vi.mock('@langgenius/dify-ui/dropdown-menu', () => { const DropdownMenuContext = React.createContext<{ isOpen: boolean, setOpen: (open: boolean) => void } | null>(null) const useDropdownMenuContext = () => { diff --git a/web/app/components/app-sidebar/app-info/__tests__/app-info-detail-panel.spec.tsx b/web/app/components/app-sidebar/app-info/__tests__/app-info-detail-panel.spec.tsx index 2171974253..9af90359f0 100644 --- a/web/app/components/app-sidebar/app-info/__tests__/app-info-detail-panel.spec.tsx +++ b/web/app/components/app-sidebar/app-info/__tests__/app-info-detail-panel.spec.tsx @@ -35,7 +35,7 @@ vi.mock('@/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/card-view', ), })) -vi.mock('@/app/components/base/ui/button', () => ({ +vi.mock('@langgenius/dify-ui/button', () => ({ Button: ({ children, onClick, className, size, variant }: { children: React.ReactNode onClick?: () => void diff --git a/web/app/components/app-sidebar/app-info/__tests__/app-operations.spec.tsx b/web/app/components/app-sidebar/app-info/__tests__/app-operations.spec.tsx index 461cedc20c..ff6aed2c71 100644 --- a/web/app/components/app-sidebar/app-info/__tests__/app-operations.spec.tsx +++ b/web/app/components/app-sidebar/app-info/__tests__/app-operations.spec.tsx @@ -4,7 +4,7 @@ import userEvent from '@testing-library/user-event' import * as React from 'react' import AppOperations from '../app-operations' -vi.mock('../../../base/ui/button', () => ({ +vi.mock('@langgenius/dify-ui/button', () => ({ Button: ({ children, onClick, className, size, variant, id, tabIndex, ...rest }: { 'children': React.ReactNode 'onClick'?: () => void @@ -30,7 +30,7 @@ vi.mock('../../../base/ui/button', () => ({ ), })) -vi.mock('../../../base/ui/dropdown-menu', () => { +vi.mock('@langgenius/dify-ui/dropdown-menu', () => { const DropdownMenuContext = React.createContext<{ isOpen: boolean, setOpen: (open: boolean) => void } | null>(null) const useDropdownMenuContext = () => { diff --git a/web/app/components/app-sidebar/app-info/__tests__/use-app-info-actions.spec.ts b/web/app/components/app-sidebar/app-info/__tests__/use-app-info-actions.spec.ts index 5b10b4c32b..23e5e51949 100644 --- a/web/app/components/app-sidebar/app-info/__tests__/use-app-info-actions.spec.ts +++ b/web/app/components/app-sidebar/app-info/__tests__/use-app-info-actions.spec.ts @@ -50,7 +50,7 @@ vi.mock('@/app/components/app/store', () => ({ }), })) -vi.mock('@/app/components/base/ui/toast', () => ({ +vi.mock('@langgenius/dify-ui/toast', () => ({ toast: Object.assign(toastMocks.api, { success: vi.fn((message, options) => toastMocks.call({ type: 'success', message, ...options })), error: vi.fn((message, options) => toastMocks.call({ type: 'error', message, ...options })), diff --git a/web/app/components/app-sidebar/app-info/app-info-detail-panel.tsx b/web/app/components/app-sidebar/app-info/app-info-detail-panel.tsx index 624630b179..9afa0063dc 100644 --- a/web/app/components/app-sidebar/app-info/app-info-detail-panel.tsx +++ b/web/app/components/app-sidebar/app-info/app-info-detail-panel.tsx @@ -1,6 +1,7 @@ import type { Operation } from './app-operations' import type { AppInfoModalType } from './use-app-info-actions' import type { App, AppSSO } from '@/types/app' +import { Button } from '@langgenius/dify-ui/button' import { RiDeleteBinLine, RiEditLine, @@ -14,7 +15,6 @@ import { useMemo } from 'react' import { useTranslation } from 'react-i18next' import CardView from '@/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/card-view' import ContentDialog from '@/app/components/base/content-dialog' -import { Button } from '@/app/components/base/ui/button' import { AppModeEnum } from '@/types/app' import AppIcon from '../../base/app-icon' import { getAppModeLabel } from './app-mode-labels' diff --git a/web/app/components/app-sidebar/app-info/app-info-modals.tsx b/web/app/components/app-sidebar/app-info/app-info-modals.tsx index fa84ecc0b6..7d142e0cc6 100644 --- a/web/app/components/app-sidebar/app-info/app-info-modals.tsx +++ b/web/app/components/app-sidebar/app-info/app-info-modals.tsx @@ -3,10 +3,6 @@ import type { DuplicateAppModalProps } from '@/app/components/app/duplicate-moda import type { CreateAppModalProps } from '@/app/components/explore/create-app-modal' import type { EnvironmentVariable } from '@/app/components/workflow/types' import type { App, AppSSO } from '@/types/app' -import * as React from 'react' -import { useState } from 'react' -import { useTranslation } from 'react-i18next' -import Input from '@/app/components/base/input' import { AlertDialog, AlertDialogActions, @@ -15,7 +11,11 @@ import { AlertDialogContent, AlertDialogDescription, AlertDialogTitle, -} from '@/app/components/base/ui/alert-dialog' +} from '@langgenius/dify-ui/alert-dialog' +import * as React from 'react' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import Input from '@/app/components/base/input' import dynamic from '@/next/dynamic' const SwitchAppModal = dynamic(() => import('@/app/components/app/switch-app-modal'), { ssr: false }) diff --git a/web/app/components/app-sidebar/app-info/app-operations.tsx b/web/app/components/app-sidebar/app-info/app-operations.tsx index 095fb31206..cc6afd739c 100644 --- a/web/app/components/app-sidebar/app-info/app-operations.tsx +++ b/web/app/components/app-sidebar/app-info/app-operations.tsx @@ -1,15 +1,15 @@ import type { JSX } from 'react' -import { RiMoreLine } from '@remixicon/react' -import { cloneElement, useEffect, useMemo, useRef, useState } from 'react' -import { useTranslation } from 'react-i18next' -import { Button } from '@/app/components/base/ui/button' +import { Button } from '@langgenius/dify-ui/button' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, -} from '../../base/ui/dropdown-menu' +} from '@langgenius/dify-ui/dropdown-menu' +import { RiMoreLine } from '@remixicon/react' +import { cloneElement, useEffect, useMemo, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' export type Operation = { id: string diff --git a/web/app/components/app-sidebar/app-info/use-app-info-actions.ts b/web/app/components/app-sidebar/app-info/use-app-info-actions.ts index 3192d48f81..5ca0e85c18 100644 --- a/web/app/components/app-sidebar/app-info/use-app-info-actions.ts +++ b/web/app/components/app-sidebar/app-info/use-app-info-actions.ts @@ -1,10 +1,10 @@ import type { DuplicateAppModalProps } from '@/app/components/app/duplicate-modal' import type { CreateAppModalProps } from '@/app/components/explore/create-app-modal' import type { EnvironmentVariable } from '@/app/components/workflow/types' +import { toast } from '@langgenius/dify-ui/toast' import { useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { useStore as useAppStore } from '@/app/components/app/store' -import { toast } from '@/app/components/base/ui/toast' import { NEED_REFRESH_APP_LIST_KEY } from '@/config' import { useProviderContext } from '@/context/provider-context' import { useRouter } from '@/next/navigation' diff --git a/web/app/components/app-sidebar/app-sidebar-dropdown.tsx b/web/app/components/app-sidebar/app-sidebar-dropdown.tsx index 617d14f426..0b5f13d918 100644 --- a/web/app/components/app-sidebar/app-sidebar-dropdown.tsx +++ b/web/app/components/app-sidebar/app-sidebar-dropdown.tsx @@ -1,5 +1,10 @@ import type { NavIcon } from './nav-link' import { cn } from '@langgenius/dify-ui/cn' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, +} from '@langgenius/dify-ui/dropdown-menu' import { RiEqualizer2Line, RiMenuLine, @@ -8,11 +13,6 @@ import * as React from 'react' import { useState } from 'react' import { useTranslation } from 'react-i18next' import { useStore as useAppStore } from '@/app/components/app/store' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuTrigger, -} from '@/app/components/base/ui/dropdown-menu' import { useAppContext } from '@/context/app-context' import AppIcon from '../base/app-icon' import Divider from '../base/divider' diff --git a/web/app/components/app-sidebar/dataset-info/__tests__/dropdown-callbacks.spec.tsx b/web/app/components/app-sidebar/dataset-info/__tests__/dropdown-callbacks.spec.tsx index b514b6e095..ceb8302ee6 100644 --- a/web/app/components/app-sidebar/dataset-info/__tests__/dropdown-callbacks.spec.tsx +++ b/web/app/components/app-sidebar/dataset-info/__tests__/dropdown-callbacks.spec.tsx @@ -112,7 +112,7 @@ vi.mock('@/service/datasets', () => ({ deleteDataset: (...args: unknown[]) => mockDeleteDataset(...args), })) -vi.mock('@/app/components/base/ui/toast', () => ({ +vi.mock('@langgenius/dify-ui/toast', () => ({ toast: (...args: unknown[]) => mockToast(...args), })) diff --git a/web/app/components/app-sidebar/dataset-info/dropdown.tsx b/web/app/components/app-sidebar/dataset-info/dropdown.tsx index e69b3d7e32..8f3a25738a 100644 --- a/web/app/components/app-sidebar/dataset-info/dropdown.tsx +++ b/web/app/components/app-sidebar/dataset-info/dropdown.tsx @@ -1,9 +1,23 @@ import type { DataSet } from '@/models/datasets' +import { + AlertDialog, + AlertDialogActions, + AlertDialogCancelButton, + AlertDialogConfirmButton, + AlertDialogContent, + AlertDialogDescription, + AlertDialogTitle, +} from '@langgenius/dify-ui/alert-dialog' import { cn } from '@langgenius/dify-ui/cn' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, +} from '@langgenius/dify-ui/dropdown-menu' +import { toast } from '@langgenius/dify-ui/toast' import * as React from 'react' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' -import { toast } from '@/app/components/base/ui/toast' import { useSelector as useAppContextWithSelector } from '@/context/app-context' import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail' import { useRouter } from '@/next/navigation' @@ -13,20 +27,6 @@ import { useInvalid } from '@/service/use-base' import { useExportPipelineDSL } from '@/service/use-pipeline' import { downloadBlob } from '@/utils/download' import ActionButton from '../../base/action-button' -import { - AlertDialog, - AlertDialogActions, - AlertDialogCancelButton, - AlertDialogConfirmButton, - AlertDialogContent, - AlertDialogDescription, - AlertDialogTitle, -} from '../../base/ui/alert-dialog' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuTrigger, -} from '../../base/ui/dropdown-menu' import RenameDatasetModal from '../../datasets/rename-modal' import Menu from './menu' diff --git a/web/app/components/app-sidebar/dataset-sidebar-dropdown.tsx b/web/app/components/app-sidebar/dataset-sidebar-dropdown.tsx index 3968a0df6f..805636fb55 100644 --- a/web/app/components/app-sidebar/dataset-sidebar-dropdown.tsx +++ b/web/app/components/app-sidebar/dataset-sidebar-dropdown.tsx @@ -1,17 +1,17 @@ import type { NavIcon } from './nav-link' import type { DataSet } from '@/models/datasets' import { cn } from '@langgenius/dify-ui/cn' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, +} from '@langgenius/dify-ui/dropdown-menu' import { RiMenuLine, } from '@remixicon/react' import * as React from 'react' import { useState } from 'react' import { useTranslation } from 'react-i18next' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuTrigger, -} from '@/app/components/base/ui/dropdown-menu' import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail' import { useKnowledge } from '@/hooks/use-knowledge' import { DOC_FORM_TEXT } from '@/models/datasets' diff --git a/web/app/components/app-sidebar/toggle-button.tsx b/web/app/components/app-sidebar/toggle-button.tsx index 5800ff2263..6aca77fb4f 100644 --- a/web/app/components/app-sidebar/toggle-button.tsx +++ b/web/app/components/app-sidebar/toggle-button.tsx @@ -1,8 +1,8 @@ +import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { RiArrowLeftSLine, RiArrowRightSLine } from '@remixicon/react' import * as React from 'react' import { useTranslation } from 'react-i18next' -import { Button } from '@/app/components/base/ui/button' import Tooltip from '../base/tooltip' import ShortcutsName from '../workflow/shortcuts-name' diff --git a/web/app/components/app/annotation/__tests__/index.spec.tsx b/web/app/components/app/annotation/__tests__/index.spec.tsx index cd9b127c7f..2bd94d03b0 100644 --- a/web/app/components/app/annotation/__tests__/index.spec.tsx +++ b/web/app/components/app/annotation/__tests__/index.spec.tsx @@ -2,9 +2,9 @@ import type { Mock } from 'vitest' import type { AnnotationItem } from '../type' import type { App } from '@/types/app' +import { toast } from '@langgenius/dify-ui/toast' import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import * as React from 'react' -import { toast } from '@/app/components/base/ui/toast' import { useProviderContext } from '@/context/provider-context' import { addAnnotation, diff --git a/web/app/components/app/annotation/add-annotation-modal/__tests__/index.spec.tsx b/web/app/components/app/annotation/add-annotation-modal/__tests__/index.spec.tsx index d7e46f9f92..497c623f3e 100644 --- a/web/app/components/app/annotation/add-annotation-modal/__tests__/index.spec.tsx +++ b/web/app/components/app/annotation/add-annotation-modal/__tests__/index.spec.tsx @@ -9,7 +9,7 @@ vi.mock('@/context/provider-context', () => ({ })) const mockToastNotify = vi.fn() -vi.mock('@/app/components/base/ui/toast', () => ({ +vi.mock('@langgenius/dify-ui/toast', () => ({ default: { notify: vi.fn(args => mockToastNotify(args)), }, diff --git a/web/app/components/app/annotation/add-annotation-modal/index.tsx b/web/app/components/app/annotation/add-annotation-modal/index.tsx index dbdf909c0a..ba987a8a8a 100644 --- a/web/app/components/app/annotation/add-annotation-modal/index.tsx +++ b/web/app/components/app/annotation/add-annotation-modal/index.tsx @@ -1,13 +1,13 @@ 'use client' import type { FC } from 'react' import type { AnnotationItemBasic } from '../type' +import { Button } from '@langgenius/dify-ui/button' +import { toast } from '@langgenius/dify-ui/toast' import * as React from 'react' import { useState } from 'react' import { useTranslation } from 'react-i18next' import Checkbox from '@/app/components/base/checkbox' import Drawer from '@/app/components/base/drawer-plus' -import { Button } from '@/app/components/base/ui/button' -import { toast } from '@/app/components/base/ui/toast' import AnnotationFull from '@/app/components/billing/annotation-full' import { useProviderContext } from '@/context/provider-context' import EditItem, { EditItemType } from './edit-item' diff --git a/web/app/components/app/annotation/batch-action.tsx b/web/app/components/app/annotation/batch-action.tsx index da7d0c70dd..938dcb03bd 100644 --- a/web/app/components/app/annotation/batch-action.tsx +++ b/web/app/components/app/annotation/batch-action.tsx @@ -1,10 +1,4 @@ import type { FC } from 'react' -import { cn } from '@langgenius/dify-ui/cn' -import { RiDeleteBinLine } from '@remixicon/react' -import { useBoolean } from 'ahooks' -import * as React from 'react' -import { useTranslation } from 'react-i18next' -import Divider from '@/app/components/base/divider' import { AlertDialog, AlertDialogActions, @@ -12,7 +6,13 @@ import { AlertDialogConfirmButton, AlertDialogContent, AlertDialogTitle, -} from '@/app/components/base/ui/alert-dialog' +} from '@langgenius/dify-ui/alert-dialog' +import { cn } from '@langgenius/dify-ui/cn' +import { RiDeleteBinLine } from '@remixicon/react' +import { useBoolean } from 'ahooks' +import * as React from 'react' +import { useTranslation } from 'react-i18next' +import Divider from '@/app/components/base/divider' const i18nPrefix = 'batchAction' diff --git a/web/app/components/app/annotation/batch-add-annotation-modal/__tests__/csv-uploader.spec.tsx b/web/app/components/app/annotation/batch-add-annotation-modal/__tests__/csv-uploader.spec.tsx index d26ab051ef..5fc1cd25e1 100644 --- a/web/app/components/app/annotation/batch-add-annotation-modal/__tests__/csv-uploader.spec.tsx +++ b/web/app/components/app/annotation/batch-add-annotation-modal/__tests__/csv-uploader.spec.tsx @@ -10,7 +10,7 @@ const toastMocks = vi.hoisted(() => ({ promise: vi.fn(), })) -vi.mock('@/app/components/base/ui/toast', () => ({ +vi.mock('@langgenius/dify-ui/toast', () => ({ toast: { success: (message: string, options?: Record) => toastMocks.notify({ type: 'success', message, ...options }), error: (message: string, options?: Record) => toastMocks.notify({ type: 'error', message, ...options }), diff --git a/web/app/components/app/annotation/batch-add-annotation-modal/__tests__/index.spec.tsx b/web/app/components/app/annotation/batch-add-annotation-modal/__tests__/index.spec.tsx index 3176b1addb..c5d7232e12 100644 --- a/web/app/components/app/annotation/batch-add-annotation-modal/__tests__/index.spec.tsx +++ b/web/app/components/app/annotation/batch-add-annotation-modal/__tests__/index.spec.tsx @@ -43,7 +43,7 @@ vi.mock('@/app/components/billing/annotation-full', () => ({ })) const mockNotify = vi.fn() -vi.mock('@/app/components/base/ui/toast', () => ({ +vi.mock('@langgenius/dify-ui/toast', () => ({ default: { notify: (args: unknown) => mockNotify(args), }, diff --git a/web/app/components/app/annotation/batch-add-annotation-modal/csv-uploader.tsx b/web/app/components/app/annotation/batch-add-annotation-modal/csv-uploader.tsx index 02dcf27aea..dc63b5c9be 100644 --- a/web/app/components/app/annotation/batch-add-annotation-modal/csv-uploader.tsx +++ b/web/app/components/app/annotation/batch-add-annotation-modal/csv-uploader.tsx @@ -1,13 +1,13 @@ 'use client' import type { FC } from 'react' +import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' +import { toast } from '@langgenius/dify-ui/toast' import { RiDeleteBinLine } from '@remixicon/react' import * as React from 'react' import { useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { Csv as CSVIcon } from '@/app/components/base/icons/src/public/files' -import { Button } from '@/app/components/base/ui/button' -import { toast } from '@/app/components/base/ui/toast' export type Props = { file: File | undefined diff --git a/web/app/components/app/annotation/batch-add-annotation-modal/index.tsx b/web/app/components/app/annotation/batch-add-annotation-modal/index.tsx index 20a35714e8..550794cce7 100644 --- a/web/app/components/app/annotation/batch-add-annotation-modal/index.tsx +++ b/web/app/components/app/annotation/batch-add-annotation-modal/index.tsx @@ -1,13 +1,13 @@ 'use client' import type { FC } from 'react' +import { Button } from '@langgenius/dify-ui/button' +import { toast } from '@langgenius/dify-ui/toast' import { RiCloseLine } from '@remixicon/react' import { noop } from 'es-toolkit/function' import * as React from 'react' import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import Modal from '@/app/components/base/modal' -import { Button } from '@/app/components/base/ui/button' -import { toast } from '@/app/components/base/ui/toast' import AnnotationFull from '@/app/components/billing/annotation-full' import { useProviderContext } from '@/context/provider-context' import { annotationBatchImport, checkAnnotationBatchImportProgress } from '@/service/annotation' diff --git a/web/app/components/app/annotation/clear-all-annotations-confirm-modal/index.tsx b/web/app/components/app/annotation/clear-all-annotations-confirm-modal/index.tsx index b960e1d59c..6b0ef19e7b 100644 --- a/web/app/components/app/annotation/clear-all-annotations-confirm-modal/index.tsx +++ b/web/app/components/app/annotation/clear-all-annotations-confirm-modal/index.tsx @@ -1,8 +1,6 @@ 'use client' import type { FC } from 'react' -import * as React from 'react' -import { useTranslation } from 'react-i18next' import { AlertDialog, AlertDialogActions, @@ -10,7 +8,9 @@ import { AlertDialogConfirmButton, AlertDialogContent, AlertDialogTitle, -} from '@/app/components/base/ui/alert-dialog' +} from '@langgenius/dify-ui/alert-dialog' +import * as React from 'react' +import { useTranslation } from 'react-i18next' type Props = { isShow: boolean diff --git a/web/app/components/app/annotation/edit-annotation-modal/__tests__/index.spec.tsx b/web/app/components/app/annotation/edit-annotation-modal/__tests__/index.spec.tsx index 4844017dde..733529dff8 100644 --- a/web/app/components/app/annotation/edit-annotation-modal/__tests__/index.spec.tsx +++ b/web/app/components/app/annotation/edit-annotation-modal/__tests__/index.spec.tsx @@ -1,6 +1,6 @@ +import { toast } from '@langgenius/dify-ui/toast' import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { toast } from '@/app/components/base/ui/toast' import EditAnnotationModal from '../index' const { mockAddAnnotation, mockEditAnnotation } = vi.hoisted(() => ({ diff --git a/web/app/components/app/annotation/edit-annotation-modal/edit-item/index.tsx b/web/app/components/app/annotation/edit-annotation-modal/edit-item/index.tsx index 4a62569816..609d7847bc 100644 --- a/web/app/components/app/annotation/edit-annotation-modal/edit-item/index.tsx +++ b/web/app/components/app/annotation/edit-annotation-modal/edit-item/index.tsx @@ -1,5 +1,6 @@ 'use client' import type { FC } from 'react' +import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { RiDeleteBinLine, RiEditFill, RiEditLine } from '@remixicon/react' import * as React from 'react' @@ -7,7 +8,6 @@ import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { Robot, User } from '@/app/components/base/icons/src/public/avatar' import Textarea from '@/app/components/base/textarea' -import { Button } from '@/app/components/base/ui/button' export enum EditItemType { Query = 'query', diff --git a/web/app/components/app/annotation/edit-annotation-modal/index.tsx b/web/app/components/app/annotation/edit-annotation-modal/index.tsx index c3d5b140eb..8e690eca9b 100644 --- a/web/app/components/app/annotation/edit-annotation-modal/index.tsx +++ b/web/app/components/app/annotation/edit-annotation-modal/index.tsx @@ -1,10 +1,5 @@ 'use client' import type { FC } from 'react' -import * as React from 'react' -import { useState } from 'react' -import { useTranslation } from 'react-i18next' -import Drawer from '@/app/components/base/drawer-plus' -import { MessageCheckRemove } from '@/app/components/base/icons/src/vender/line/communication' import { AlertDialog, AlertDialogActions, @@ -12,8 +7,13 @@ import { AlertDialogConfirmButton, AlertDialogContent, AlertDialogTitle, -} from '@/app/components/base/ui/alert-dialog' -import { toast } from '@/app/components/base/ui/toast' +} from '@langgenius/dify-ui/alert-dialog' +import { toast } from '@langgenius/dify-ui/toast' +import * as React from 'react' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import Drawer from '@/app/components/base/drawer-plus' +import { MessageCheckRemove } from '@/app/components/base/icons/src/vender/line/communication' import AnnotationFull from '@/app/components/billing/annotation-full' import { useProviderContext } from '@/context/provider-context' import useTimestamp from '@/hooks/use-timestamp' diff --git a/web/app/components/app/annotation/header-opts/index.tsx b/web/app/components/app/annotation/header-opts/index.tsx index 0871a722f8..fc27524c71 100644 --- a/web/app/components/app/annotation/header-opts/index.tsx +++ b/web/app/components/app/annotation/header-opts/index.tsx @@ -2,19 +2,19 @@ import type { FC } from 'react' import type { AnnotationItemBasic } from '../type' import { Menu, MenuButton, MenuItems, Transition } from '@headlessui/react' +import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, +} from '@langgenius/dify-ui/dropdown-menu' import * as React from 'react' import { Fragment, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { useCSVDownloader, } from 'react-papaparse' -import { Button } from '@/app/components/base/ui/button' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuTrigger, -} from '@/app/components/base/ui/dropdown-menu' import { useLocale } from '@/context/i18n' import { LanguagesSupported } from '@/i18n-config/language' diff --git a/web/app/components/app/annotation/index.tsx b/web/app/components/app/annotation/index.tsx index 419aaba25d..cbad5b40ea 100644 --- a/web/app/components/app/annotation/index.tsx +++ b/web/app/components/app/annotation/index.tsx @@ -5,6 +5,8 @@ import type { AnnotationItem, AnnotationItemBasic } from './type' import type { AnnotationReplyConfig } from '@/models/debug' import type { App } from '@/types/app' import { cn } from '@langgenius/dify-ui/cn' +import { Switch } from '@langgenius/dify-ui/switch' +import { toast } from '@langgenius/dify-ui/toast' import { RiEqualizer2Line } from '@remixicon/react' import { useDebounce } from 'ahooks' import * as React from 'react' @@ -15,8 +17,6 @@ import ConfigParamModal from '@/app/components/base/features/new-feature-panel/a import { MessageFast } from '@/app/components/base/icons/src/vender/solid/communication' import Loading from '@/app/components/base/loading' import Pagination from '@/app/components/base/pagination' -import Switch from '@/app/components/base/switch' -import { toast } from '@/app/components/base/ui/toast' import AnnotationFullModal from '@/app/components/billing/annotation-full/modal' import { APP_PAGE_LIMIT } from '@/config' import { useProviderContext } from '@/context/provider-context' diff --git a/web/app/components/app/annotation/remove-annotation-confirm-modal/index.tsx b/web/app/components/app/annotation/remove-annotation-confirm-modal/index.tsx index 70e28b048c..864e098ca5 100644 --- a/web/app/components/app/annotation/remove-annotation-confirm-modal/index.tsx +++ b/web/app/components/app/annotation/remove-annotation-confirm-modal/index.tsx @@ -1,7 +1,5 @@ 'use client' import type { FC } from 'react' -import * as React from 'react' -import { useTranslation } from 'react-i18next' import { AlertDialog, AlertDialogActions, @@ -9,7 +7,9 @@ import { AlertDialogConfirmButton, AlertDialogContent, AlertDialogTitle, -} from '@/app/components/base/ui/alert-dialog' +} from '@langgenius/dify-ui/alert-dialog' +import * as React from 'react' +import { useTranslation } from 'react-i18next' type Props = { isShow: boolean diff --git a/web/app/components/app/annotation/view-annotation-modal/index.tsx b/web/app/components/app/annotation/view-annotation-modal/index.tsx index 8a5b53aa87..c9f7e8a78f 100644 --- a/web/app/components/app/annotation/view-annotation-modal/index.tsx +++ b/web/app/components/app/annotation/view-annotation-modal/index.tsx @@ -1,6 +1,14 @@ 'use client' import type { FC } from 'react' import type { AnnotationItem, HitHistoryItem } from '../type' +import { + AlertDialog, + AlertDialogActions, + AlertDialogCancelButton, + AlertDialogConfirmButton, + AlertDialogContent, + AlertDialogTitle, +} from '@langgenius/dify-ui/alert-dialog' import { cn } from '@langgenius/dify-ui/cn' import * as React from 'react' import { useEffect, useState } from 'react' @@ -10,14 +18,6 @@ import Drawer from '@/app/components/base/drawer-plus' import { MessageCheckRemove } from '@/app/components/base/icons/src/vender/line/communication' import Pagination from '@/app/components/base/pagination' import TabSlider from '@/app/components/base/tab-slider-plain' -import { - AlertDialog, - AlertDialogActions, - AlertDialogCancelButton, - AlertDialogConfirmButton, - AlertDialogContent, - AlertDialogTitle, -} from '@/app/components/base/ui/alert-dialog' import { APP_PAGE_LIMIT } from '@/config' import useTimestamp from '@/hooks/use-timestamp' import { fetchHitHistoryList } from '@/service/annotation' diff --git a/web/app/components/app/app-access-control/__tests__/access-control.spec.tsx b/web/app/components/app/app-access-control/__tests__/access-control.spec.tsx index 86ed6ac435..bbac21942e 100644 --- a/web/app/components/app/app-access-control/__tests__/access-control.spec.tsx +++ b/web/app/components/app/app-access-control/__tests__/access-control.spec.tsx @@ -1,9 +1,9 @@ /* eslint-disable ts/no-explicit-any */ import type { AccessControlAccount, AccessControlGroup, Subject } from '@/models/access-control' import type { App } from '@/types/app' +import { toast } from '@langgenius/dify-ui/toast' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { toast } from '@/app/components/base/ui/toast' import useAccessControlStore from '@/context/access-control-store' import { AccessMode, SubjectType } from '@/models/access-control' import AccessControlDialog from '../access-control-dialog' diff --git a/web/app/components/app/app-access-control/__tests__/index.spec.tsx b/web/app/components/app/app-access-control/__tests__/index.spec.tsx index 612d17d88a..05b86f0290 100644 --- a/web/app/components/app/app-access-control/__tests__/index.spec.tsx +++ b/web/app/components/app/app-access-control/__tests__/index.spec.tsx @@ -1,6 +1,6 @@ import type { App } from '@/types/app' +import { toast } from '@langgenius/dify-ui/toast' import { fireEvent, render, screen, waitFor } from '@testing-library/react' -import { toast } from '@/app/components/base/ui/toast' import useAccessControlStore from '@/context/access-control-store' import { AccessMode } from '@/models/access-control' import AccessControl from '../index' diff --git a/web/app/components/app/app-access-control/add-member-or-group-pop.tsx b/web/app/components/app/app-access-control/add-member-or-group-pop.tsx index 8c8087cf17..38f9c2ab50 100644 --- a/web/app/components/app/app-access-control/add-member-or-group-pop.tsx +++ b/web/app/components/app/app-access-control/add-member-or-group-pop.tsx @@ -1,14 +1,14 @@ 'use client' import type { AccessControlAccount, AccessControlGroup, Subject, SubjectAccount, SubjectGroup } from '@/models/access-control' import { FloatingOverlay } from '@floating-ui/react' +import { Avatar } from '@langgenius/dify-ui/avatar' +import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' +import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import { RiAddCircleFill, RiArrowRightSLine, RiOrganizationChart } from '@remixicon/react' import { useDebounce } from 'ahooks' import { useCallback, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { Avatar } from '@/app/components/base/ui/avatar' -import { Button } from '@/app/components/base/ui/button' -import { Popover, PopoverContent, PopoverTrigger } from '@/app/components/base/ui/popover' import { useSelector } from '@/context/app-context' import { SubjectType } from '@/models/access-control' import { useSearchForWhiteListCandidates } from '@/service/access-control' diff --git a/web/app/components/app/app-access-control/index.tsx b/web/app/components/app/app-access-control/index.tsx index aeaae3d46d..2997d4b4cf 100644 --- a/web/app/components/app/app-access-control/index.tsx +++ b/web/app/components/app/app-access-control/index.tsx @@ -2,11 +2,11 @@ import type { Subject } from '@/models/access-control' import type { App } from '@/types/app' import { Description as DialogDescription, DialogTitle } from '@headlessui/react' +import { Button } from '@langgenius/dify-ui/button' +import { toast } from '@langgenius/dify-ui/toast' import { RiBuildingLine, RiGlobalLine, RiVerifiedBadgeLine } from '@remixicon/react' import { useCallback, useEffect } from 'react' import { useTranslation } from 'react-i18next' -import { Button } from '@/app/components/base/ui/button' -import { toast } from '@/app/components/base/ui/toast' import { useGlobalPublicStore } from '@/context/global-public-context' import { AccessMode, SubjectType } from '@/models/access-control' import { useUpdateAccessMode } from '@/service/access-control' diff --git a/web/app/components/app/app-access-control/specific-groups-or-members.tsx b/web/app/components/app/app-access-control/specific-groups-or-members.tsx index 5b5c8342fc..1caabb3ff9 100644 --- a/web/app/components/app/app-access-control/specific-groups-or-members.tsx +++ b/web/app/components/app/app-access-control/specific-groups-or-members.tsx @@ -1,9 +1,9 @@ 'use client' import type { AccessControlAccount, AccessControlGroup } from '@/models/access-control' +import { Avatar } from '@langgenius/dify-ui/avatar' import { RiAlertFill, RiCloseCircleFill, RiLockLine, RiOrganizationChart } from '@remixicon/react' import { useCallback, useEffect } from 'react' import { useTranslation } from 'react-i18next' -import { Avatar } from '@/app/components/base/ui/avatar' import { AccessMode } from '@/models/access-control' import { useAppWhiteListSubjects } from '@/service/access-control' import useAccessControlStore from '../../../../context/access-control-store' diff --git a/web/app/components/app/app-publisher/__tests__/index.spec.tsx b/web/app/components/app/app-publisher/__tests__/index.spec.tsx index bba2f53afc..06d91e9400 100644 --- a/web/app/components/app/app-publisher/__tests__/index.spec.tsx +++ b/web/app/components/app/app-publisher/__tests__/index.spec.tsx @@ -93,7 +93,7 @@ vi.mock('@/service/use-workflow', () => ({ useInvalidateAppWorkflow: () => mockInvalidateAppWorkflow, })) -vi.mock('@/app/components/base/ui/toast', () => ({ +vi.mock('@langgenius/dify-ui/toast', () => ({ toast: { error: (...args: unknown[]) => mockToastError(...args), }, diff --git a/web/app/components/app/app-publisher/__tests__/publish-with-multiple-model.spec.tsx b/web/app/components/app/app-publisher/__tests__/publish-with-multiple-model.spec.tsx index 465252c6c4..16628d742a 100644 --- a/web/app/components/app/app-publisher/__tests__/publish-with-multiple-model.spec.tsx +++ b/web/app/components/app/app-publisher/__tests__/publish-with-multiple-model.spec.tsx @@ -22,7 +22,7 @@ vi.mock('../../header/account-setting/model-provider-page/model-icon', () => ({ default: ({ modelName }: { modelName: string }) => {modelName}, })) -vi.mock('@/app/components/base/ui/dropdown-menu', async () => { +vi.mock('@langgenius/dify-ui/dropdown-menu', async () => { const ReactModule = await vi.importActual('react') const OpenContext = ReactModule.createContext<{ open: boolean, setOpen: (nextOpen: boolean) => void } | null>(null) diff --git a/web/app/components/app/app-publisher/__tests__/version-info-modal.spec.tsx b/web/app/components/app/app-publisher/__tests__/version-info-modal.spec.tsx index ed86bb8571..4a461bd942 100644 --- a/web/app/components/app/app-publisher/__tests__/version-info-modal.spec.tsx +++ b/web/app/components/app/app-publisher/__tests__/version-info-modal.spec.tsx @@ -1,6 +1,6 @@ /* eslint-disable ts/no-explicit-any */ +import { toast } from '@langgenius/dify-ui/toast' import { fireEvent, render, screen } from '@testing-library/react' -import { toast } from '@/app/components/base/ui/toast' import VersionInfoModal from '../version-info-modal' vi.mock('react-i18next', () => ({ @@ -9,7 +9,7 @@ vi.mock('react-i18next', () => ({ }), })) -vi.mock('@/app/components/base/ui/toast', () => ({ +vi.mock('@langgenius/dify-ui/toast', () => ({ toast: { error: vi.fn(), }, diff --git a/web/app/components/app/app-publisher/features-wrapper.tsx b/web/app/components/app/app-publisher/features-wrapper.tsx index 7c39535701..8679b2830d 100644 --- a/web/app/components/app/app-publisher/features-wrapper.tsx +++ b/web/app/components/app/app-publisher/features-wrapper.tsx @@ -2,13 +2,6 @@ import type { AppPublisherProps } from '@/app/components/app/app-publisher' import type { ModelAndParameter } from '@/app/components/app/configuration/debug/types' import type { FileUpload } from '@/app/components/base/features/types' import type { PublishWorkflowParams } from '@/types/workflow' -import { produce } from 'immer' -import * as React from 'react' -import { useCallback, useState } from 'react' -import { useTranslation } from 'react-i18next' -import AppPublisher from '@/app/components/app/app-publisher' -import { useFeatures, useFeaturesStore } from '@/app/components/base/features/hooks' -import { FILE_EXTS } from '@/app/components/base/prompt-editor/constants' import { AlertDialog, AlertDialogActions, @@ -17,7 +10,14 @@ import { AlertDialogContent, AlertDialogDescription, AlertDialogTitle, -} from '@/app/components/base/ui/alert-dialog' +} from '@langgenius/dify-ui/alert-dialog' +import { produce } from 'immer' +import * as React from 'react' +import { useCallback, useState } from 'react' +import { useTranslation } from 'react-i18next' +import AppPublisher from '@/app/components/app/app-publisher' +import { useFeatures, useFeaturesStore } from '@/app/components/base/features/hooks' +import { FILE_EXTS } from '@/app/components/base/prompt-editor/constants' import { SupportUploadFileTypes } from '@/app/components/workflow/types' import { Resolution } from '@/types/app' diff --git a/web/app/components/app/app-publisher/index.tsx b/web/app/components/app/app-publisher/index.tsx index ce3698dbb7..562f2d7759 100644 --- a/web/app/components/app/app-publisher/index.tsx +++ b/web/app/components/app/app-publisher/index.tsx @@ -2,6 +2,9 @@ import type { ModelAndParameter } from '../configuration/debug/types' import type { CollaborationUpdate } from '@/app/components/workflow/collaboration/types/collaboration' import type { InputVar, Variable } from '@/app/components/workflow/types' import type { PublishWorkflowParams } from '@/types/workflow' +import { Button } from '@langgenius/dify-ui/button' +import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' +import { toast } from '@langgenius/dify-ui/toast' import { useKeyPress } from 'ahooks' import { memo, @@ -15,8 +18,6 @@ import { useTranslation } from 'react-i18next' import EmbeddedModal from '@/app/components/app/overview/embedded' import { useStore as useAppStore } from '@/app/components/app/store' import { trackEvent } from '@/app/components/base/amplitude' -import { Button } from '@/app/components/base/ui/button' -import { Popover, PopoverContent, PopoverTrigger } from '@/app/components/base/ui/popover' import { collaborationManager } from '@/app/components/workflow/collaboration/core/collaboration-manager' import { webSocketClient } from '@/app/components/workflow/collaboration/core/websocket-manager' import { WorkflowContext } from '@/app/components/workflow/context' @@ -31,7 +32,6 @@ import { useInvalidateAppWorkflow } from '@/service/use-workflow' import { fetchPublishedWorkflow } from '@/service/workflow' import { AppModeEnum } from '@/types/app' import { basePath } from '@/utils/var' -import { toast } from '../../base/ui/toast' import { getKeyboardKeyCodeBySystem } from '../../workflow/utils' import AccessControl from '../app-access-control' import { diff --git a/web/app/components/app/app-publisher/publish-with-multiple-model.tsx b/web/app/components/app/app-publisher/publish-with-multiple-model.tsx index fbff371577..038b9328bb 100644 --- a/web/app/components/app/app-publisher/publish-with-multiple-model.tsx +++ b/web/app/components/app/app-publisher/publish-with-multiple-model.tsx @@ -1,16 +1,16 @@ import type { FC } from 'react' import type { ModelAndParameter } from '../configuration/debug/types' import type { Model, ModelItem } from '@/app/components/header/account-setting/model-provider-page/declarations' -import { RiArrowDownSLine } from '@remixicon/react' -import { useState } from 'react' -import { useTranslation } from 'react-i18next' -import { Button } from '@/app/components/base/ui/button' +import { Button } from '@langgenius/dify-ui/button' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, -} from '@/app/components/base/ui/dropdown-menu' +} from '@langgenius/dify-ui/dropdown-menu' +import { RiArrowDownSLine } from '@remixicon/react' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks' import { useProviderContext } from '@/context/provider-context' import ModelIcon from '../../header/account-setting/model-provider-page/model-icon' diff --git a/web/app/components/app/app-publisher/sections.tsx b/web/app/components/app/app-publisher/sections.tsx index d4864a3763..57522095ae 100644 --- a/web/app/components/app/app-publisher/sections.tsx +++ b/web/app/components/app/app-publisher/sections.tsx @@ -2,16 +2,16 @@ import type { CSSProperties, ReactNode } from 'react' import type { ModelAndParameter } from '../configuration/debug/types' import type { AppPublisherProps } from './index' import type { PublishWorkflowParams } from '@/types/workflow' -import { useTranslation } from 'react-i18next' -import Divider from '@/app/components/base/divider' -import { CodeBrowser } from '@/app/components/base/icons/src/vender/line/development' -import Loading from '@/app/components/base/loading' -import { Button } from '@/app/components/base/ui/button' +import { Button } from '@langgenius/dify-ui/button' import { Tooltip, TooltipContent, TooltipTrigger, -} from '@/app/components/base/ui/tooltip' +} from '@langgenius/dify-ui/tooltip' +import { useTranslation } from 'react-i18next' +import Divider from '@/app/components/base/divider' +import { CodeBrowser } from '@/app/components/base/icons/src/vender/line/development' +import Loading from '@/app/components/base/loading' import UpgradeBtn from '@/app/components/billing/upgrade-btn' import WorkflowToolConfigureButton from '@/app/components/tools/workflow-tool/configure-button' import { appDefaultIconBackground } from '@/config' diff --git a/web/app/components/app/app-publisher/version-info-modal.tsx b/web/app/components/app/app-publisher/version-info-modal.tsx index d5f4c9c189..3fe4c61fb7 100644 --- a/web/app/components/app/app-publisher/version-info-modal.tsx +++ b/web/app/components/app/app-publisher/version-info-modal.tsx @@ -1,12 +1,12 @@ import type { FC } from 'react' import type { VersionHistory } from '@/types/workflow' +import { Button } from '@langgenius/dify-ui/button' +import { toast } from '@langgenius/dify-ui/toast' import { RiCloseLine } from '@remixicon/react' import * as React from 'react' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import Modal from '@/app/components/base/modal' -import { Button } from '@/app/components/base/ui/button' -import { toast } from '@/app/components/base/ui/toast' import Input from '../../base/input' import Textarea from '../../base/textarea' diff --git a/web/app/components/app/configuration/base/warning-mask/cannot-query-dataset.tsx b/web/app/components/app/configuration/base/warning-mask/cannot-query-dataset.tsx index 029743dfb6..51b365f446 100644 --- a/web/app/components/app/configuration/base/warning-mask/cannot-query-dataset.tsx +++ b/web/app/components/app/configuration/base/warning-mask/cannot-query-dataset.tsx @@ -1,8 +1,8 @@ 'use client' import type { FC } from 'react' +import { Button } from '@langgenius/dify-ui/button' import * as React from 'react' import { useTranslation } from 'react-i18next' -import { Button } from '@/app/components/base/ui/button' import WarningMask from '.' type IFormattingChangedProps = { diff --git a/web/app/components/app/configuration/base/warning-mask/formatting-changed.tsx b/web/app/components/app/configuration/base/warning-mask/formatting-changed.tsx index 4b25b023b2..07467a5ab1 100644 --- a/web/app/components/app/configuration/base/warning-mask/formatting-changed.tsx +++ b/web/app/components/app/configuration/base/warning-mask/formatting-changed.tsx @@ -1,8 +1,8 @@ 'use client' import type { FC } from 'react' +import { Button } from '@langgenius/dify-ui/button' import * as React from 'react' import { useTranslation } from 'react-i18next' -import { Button } from '@/app/components/base/ui/button' import WarningMask from '.' type IFormattingChangedProps = { diff --git a/web/app/components/app/configuration/config-prompt/__tests__/advanced-prompt-input.spec.tsx b/web/app/components/app/configuration/config-prompt/__tests__/advanced-prompt-input.spec.tsx index f7cfff94fb..ced1ddcfe3 100644 --- a/web/app/components/app/configuration/config-prompt/__tests__/advanced-prompt-input.spec.tsx +++ b/web/app/components/app/configuration/config-prompt/__tests__/advanced-prompt-input.spec.tsx @@ -59,7 +59,7 @@ vi.mock('@/context/modal-context', () => ({ }), })) -vi.mock('@/app/components/base/ui/toast', () => ({ +vi.mock('@langgenius/dify-ui/toast', () => ({ toast: { error: (...args: unknown[]) => mockToastError(...args), }, diff --git a/web/app/components/app/configuration/config-prompt/__tests__/simple-prompt-input.spec.tsx b/web/app/components/app/configuration/config-prompt/__tests__/simple-prompt-input.spec.tsx index 0f8b9b032f..b4e6e0be2f 100644 --- a/web/app/components/app/configuration/config-prompt/__tests__/simple-prompt-input.spec.tsx +++ b/web/app/components/app/configuration/config-prompt/__tests__/simple-prompt-input.spec.tsx @@ -57,7 +57,7 @@ vi.mock('@/context/modal-context', () => ({ }), })) -vi.mock('@/app/components/base/ui/toast', () => ({ +vi.mock('@langgenius/dify-ui/toast', () => ({ toast: { error: (...args: unknown[]) => mockToastError(...args), }, diff --git a/web/app/components/app/configuration/config-prompt/advanced-prompt-input.tsx b/web/app/components/app/configuration/config-prompt/advanced-prompt-input.tsx index 1de6e6ce0c..5fd394ad45 100644 --- a/web/app/components/app/configuration/config-prompt/advanced-prompt-input.tsx +++ b/web/app/components/app/configuration/config-prompt/advanced-prompt-input.tsx @@ -2,7 +2,14 @@ import type { FC } from 'react' import type { ExternalDataTool } from '@/models/common' import type { PromptRole, PromptVariable } from '@/models/debug' +import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' +import { toast } from '@langgenius/dify-ui/toast' +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@langgenius/dify-ui/tooltip' import { RiDeleteBinLine, RiErrorWarningFill, @@ -20,13 +27,6 @@ import { } from '@/app/components/base/icons/src/vender/line/files' import PromptEditor from '@/app/components/base/prompt-editor' import { INSERT_VARIABLE_VALUE_BLOCK_COMMAND } from '@/app/components/base/prompt-editor/plugins/variable-block' -import { Button } from '@/app/components/base/ui/button' -import { toast } from '@/app/components/base/ui/toast' -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from '@/app/components/base/ui/tooltip' import ConfigContext from '@/context/debug-configuration' import { useEventEmitterContextContext } from '@/context/event-emitter' import { useModalContext } from '@/context/modal-context' diff --git a/web/app/components/app/configuration/config-prompt/confirm-add-var/index.tsx b/web/app/components/app/configuration/config-prompt/confirm-add-var/index.tsx index 03755df018..8846530af2 100644 --- a/web/app/components/app/configuration/config-prompt/confirm-add-var/index.tsx +++ b/web/app/components/app/configuration/config-prompt/confirm-add-var/index.tsx @@ -1,9 +1,9 @@ 'use client' import type { FC } from 'react' +import { Button } from '@langgenius/dify-ui/button' import * as React from 'react' import { useRef } from 'react' import { useTranslation } from 'react-i18next' -import { Button } from '@/app/components/base/ui/button' import VarHighlight from '../../base/var-highlight' type IConfirmAddVarProps = { diff --git a/web/app/components/app/configuration/config-prompt/conversation-history/edit-modal.tsx b/web/app/components/app/configuration/config-prompt/conversation-history/edit-modal.tsx index 477e1db4a0..7906a5f8da 100644 --- a/web/app/components/app/configuration/config-prompt/conversation-history/edit-modal.tsx +++ b/web/app/components/app/configuration/config-prompt/conversation-history/edit-modal.tsx @@ -1,11 +1,11 @@ 'use client' import type { FC } from 'react' import type { ConversationHistoriesRole } from '@/models/debug' +import { Button } from '@langgenius/dify-ui/button' import * as React from 'react' import { useState } from 'react' import { useTranslation } from 'react-i18next' import Modal from '@/app/components/base/modal' -import { Button } from '@/app/components/base/ui/button' type Props = { isShow: boolean diff --git a/web/app/components/app/configuration/config-prompt/index.tsx b/web/app/components/app/configuration/config-prompt/index.tsx index 8a77d4bb58..20463da174 100644 --- a/web/app/components/app/configuration/config-prompt/index.tsx +++ b/web/app/components/app/configuration/config-prompt/index.tsx @@ -2,6 +2,7 @@ import type { FC } from 'react' import type { PromptItem, PromptVariable } from '@/models/debug' import type { AppModeEnum } from '@/types/app' +import { Button } from '@langgenius/dify-ui/button' import { RiAddLine, } from '@remixicon/react' @@ -10,7 +11,6 @@ import * as React from 'react' import { useTranslation } from 'react-i18next' import { useContext } from 'use-context-selector' import AdvancedMessageInput from '@/app/components/app/configuration/config-prompt/advanced-prompt-input' -import { Button } from '@/app/components/base/ui/button' import { MAX_PROMPT_MESSAGE_LENGTH } from '@/config' import ConfigContext from '@/context/debug-configuration' import { PromptRole } from '@/models/debug' diff --git a/web/app/components/app/configuration/config-prompt/simple-prompt-input.tsx b/web/app/components/app/configuration/config-prompt/simple-prompt-input.tsx index 6b5c3acccb..2935504f15 100644 --- a/web/app/components/app/configuration/config-prompt/simple-prompt-input.tsx +++ b/web/app/components/app/configuration/config-prompt/simple-prompt-input.tsx @@ -4,6 +4,12 @@ import type { ExternalDataTool } from '@/models/common' import type { PromptVariable } from '@/models/debug' import type { GenRes } from '@/service/debug' import { cn } from '@langgenius/dify-ui/cn' +import { toast } from '@langgenius/dify-ui/toast' +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@langgenius/dify-ui/tooltip' import { useBoolean } from 'ahooks' import { noop } from 'es-toolkit/function' import { produce } from 'immer' @@ -18,12 +24,6 @@ import { useFeaturesStore } from '@/app/components/base/features/hooks' import PromptEditor from '@/app/components/base/prompt-editor' import { PROMPT_EDITOR_UPDATE_VALUE_BY_EVENT_EMITTER } from '@/app/components/base/prompt-editor/plugins/update-block' import { INSERT_VARIABLE_VALUE_BLOCK_COMMAND } from '@/app/components/base/prompt-editor/plugins/variable-block' -import { toast } from '@/app/components/base/ui/toast' -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from '@/app/components/base/ui/tooltip' import ConfigContext from '@/context/debug-configuration' import { useEventEmitterContextContext } from '@/context/event-emitter' import { useModalContext } from '@/context/modal-context' diff --git a/web/app/components/app/configuration/config-var/__tests__/index.spec.tsx b/web/app/components/app/configuration/config-var/__tests__/index.spec.tsx index 4486489a20..51683ad948 100644 --- a/web/app/components/app/configuration/config-var/__tests__/index.spec.tsx +++ b/web/app/components/app/configuration/config-var/__tests__/index.spec.tsx @@ -2,10 +2,10 @@ import type { ReactNode } from 'react' import type { IConfigVarProps } from '../index' import type { ExternalDataTool } from '@/models/common' import type { PromptVariable } from '@/models/debug' +import { toast } from '@langgenius/dify-ui/toast' import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react' import * as React from 'react' import { vi } from 'vitest' -import { toast } from '@/app/components/base/ui/toast' import DebugConfigurationContext from '@/context/debug-configuration' import { AppModeEnum } from '@/types/app' diff --git a/web/app/components/app/configuration/config-var/config-modal/__tests__/form-fields.spec.tsx b/web/app/components/app/configuration/config-var/config-modal/__tests__/form-fields.spec.tsx index 8cc8775bf5..5f0f4129d6 100644 --- a/web/app/components/app/configuration/config-var/config-modal/__tests__/form-fields.spec.tsx +++ b/web/app/components/app/configuration/config-var/config-modal/__tests__/form-fields.spec.tsx @@ -44,8 +44,8 @@ vi.mock('@/app/components/base/select', () => ({ ), })) -vi.mock('@/app/components/base/ui/select', async (importOriginal) => { - const actual = await importOriginal() +vi.mock('@langgenius/dify-ui/select', async (importOriginal) => { + const actual = await importOriginal() return { ...actual, diff --git a/web/app/components/app/configuration/config-var/config-modal/__tests__/index-logic.spec.tsx b/web/app/components/app/configuration/config-var/config-modal/__tests__/index-logic.spec.tsx index 4888d284d2..77f2b54533 100644 --- a/web/app/components/app/configuration/config-var/config-modal/__tests__/index-logic.spec.tsx +++ b/web/app/components/app/configuration/config-var/config-modal/__tests__/index-logic.spec.tsx @@ -1,10 +1,10 @@ /* eslint-disable ts/no-explicit-any */ import type { InputVar } from '@/app/components/workflow/types' import type { App, AppSSO } from '@/types/app' +import { toast } from '@langgenius/dify-ui/toast' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import * as React from 'react' import { useStore } from '@/app/components/app/store' -import { toast } from '@/app/components/base/ui/toast' import { InputVarType } from '@/app/components/workflow/types' import DebugConfigurationContext from '@/context/debug-configuration' import { AppModeEnum } from '@/types/app' diff --git a/web/app/components/app/configuration/config-var/config-modal/__tests__/index.spec.tsx b/web/app/components/app/configuration/config-var/config-modal/__tests__/index.spec.tsx index d15d6527c5..094a293943 100644 --- a/web/app/components/app/configuration/config-var/config-modal/__tests__/index.spec.tsx +++ b/web/app/components/app/configuration/config-var/config-modal/__tests__/index.spec.tsx @@ -1,9 +1,9 @@ import type { InputVar } from '@/app/components/workflow/types' import type { App, AppSSO } from '@/types/app' +import { toast } from '@langgenius/dify-ui/toast' import { fireEvent, render, screen } from '@testing-library/react' import * as React from 'react' import { useStore } from '@/app/components/app/store' -import { toast } from '@/app/components/base/ui/toast' import { InputVarType } from '@/app/components/workflow/types' import { AppModeEnum } from '@/types/app' import ConfigModal from '../index' diff --git a/web/app/components/app/configuration/config-var/config-modal/form-fields.tsx b/web/app/components/app/configuration/config-var/config-modal/form-fields.tsx index 279a9279cf..748108e19a 100644 --- a/web/app/components/app/configuration/config-var/config-modal/form-fields.tsx +++ b/web/app/components/app/configuration/config-var/config-modal/form-fields.tsx @@ -3,11 +3,6 @@ import type { ChangeEvent, FC } from 'react' import type { Item as SelectOptionItem } from './type-select' import type { FileEntity } from '@/app/components/base/file-uploader/types' import type { InputVar, UploadFileSetting } from '@/app/components/workflow/types' -import * as React from 'react' -import Checkbox from '@/app/components/base/checkbox' -import { FileUploaderInAttachmentWrapper } from '@/app/components/base/file-uploader' -import Input from '@/app/components/base/input' -import Textarea from '@/app/components/base/textarea' import { Select, SelectContent, @@ -16,7 +11,12 @@ import { SelectItemText, SelectTrigger, SelectValue, -} from '@/app/components/base/ui/select' +} from '@langgenius/dify-ui/select' +import * as React from 'react' +import Checkbox from '@/app/components/base/checkbox' +import { FileUploaderInAttachmentWrapper } from '@/app/components/base/file-uploader' +import Input from '@/app/components/base/input' +import Textarea from '@/app/components/base/textarea' import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor' import FileUploadSetting from '@/app/components/workflow/nodes/_base/components/file-upload-setting' import { CodeLanguage } from '@/app/components/workflow/nodes/code/types' diff --git a/web/app/components/app/configuration/config-var/config-modal/index.tsx b/web/app/components/app/configuration/config-var/config-modal/index.tsx index 18484683fa..824b8a8d82 100644 --- a/web/app/components/app/configuration/config-var/config-modal/index.tsx +++ b/web/app/components/app/configuration/config-var/config-modal/index.tsx @@ -2,13 +2,13 @@ import type { ChangeEvent, FC } from 'react' import type { Item as SelectItem } from './type-select' import type { InputVar, InputVarType, MoreInfo } from '@/app/components/workflow/types' +import { toast } from '@langgenius/dify-ui/toast' import * as React from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { useContext } from 'use-context-selector' import { useStore as useAppStore } from '@/app/components/app/store' import Modal from '@/app/components/base/modal' -import { toast } from '@/app/components/base/ui/toast' import ConfigContext from '@/context/debug-configuration' import { AppModeEnum } from '@/types/app' import { checkKeys, getNewVarInWorkflow, replaceSpaceWithUnderscoreInVarNameInput } from '@/utils/var' diff --git a/web/app/components/app/configuration/config-var/index.tsx b/web/app/components/app/configuration/config-var/index.tsx index aca2817249..34c6bf5786 100644 --- a/web/app/components/app/configuration/config-var/index.tsx +++ b/web/app/components/app/configuration/config-var/index.tsx @@ -4,15 +4,6 @@ import type { InputVar } from '@/app/components/workflow/types' import type { ExternalDataTool } from '@/models/common' import type { PromptVariable } from '@/models/debug' import type { I18nKeysByPrefix } from '@/types/i18n' -import { cn } from '@langgenius/dify-ui/cn' -import { useBoolean } from 'ahooks' -import { produce } from 'immer' -import * as React from 'react' -import { useCallback, useMemo, useState } from 'react' -import { useTranslation } from 'react-i18next' -import { ReactSortable } from 'react-sortablejs' -import { useContext } from 'use-context-selector' -import Tooltip from '@/app/components/base/tooltip' import { AlertDialog, AlertDialogActions, @@ -21,8 +12,17 @@ import { AlertDialogContent, AlertDialogDescription, AlertDialogTitle, -} from '@/app/components/base/ui/alert-dialog' -import { toast } from '@/app/components/base/ui/toast' +} from '@langgenius/dify-ui/alert-dialog' +import { cn } from '@langgenius/dify-ui/cn' +import { toast } from '@langgenius/dify-ui/toast' +import { useBoolean } from 'ahooks' +import { produce } from 'immer' +import * as React from 'react' +import { useCallback, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { ReactSortable } from 'react-sortablejs' +import { useContext } from 'use-context-selector' +import Tooltip from '@/app/components/base/tooltip' import { InputVarType } from '@/app/components/workflow/types' import ConfigContext from '@/context/debug-configuration' import { useEventEmitterContextContext } from '@/context/event-emitter' diff --git a/web/app/components/app/configuration/config-var/modal-foot.tsx b/web/app/components/app/configuration/config-var/modal-foot.tsx index 3168d2a273..d8a869610f 100644 --- a/web/app/components/app/configuration/config-var/modal-foot.tsx +++ b/web/app/components/app/configuration/config-var/modal-foot.tsx @@ -1,8 +1,8 @@ 'use client' import type { FC } from 'react' +import { Button } from '@langgenius/dify-ui/button' import * as React from 'react' import { useTranslation } from 'react-i18next' -import { Button } from '@/app/components/base/ui/button' type IModalFootProps = { onConfirm: () => void diff --git a/web/app/components/app/configuration/config-vision/index.tsx b/web/app/components/app/configuration/config-vision/index.tsx index e1fd88eb3f..b9cb54cc34 100644 --- a/web/app/components/app/configuration/config-vision/index.tsx +++ b/web/app/components/app/configuration/config-vision/index.tsx @@ -1,6 +1,7 @@ 'use client' import type { FC } from 'react' import { cn } from '@langgenius/dify-ui/cn' +import { Switch } from '@langgenius/dify-ui/switch' import { noop } from 'es-toolkit/function' import { produce } from 'immer' import * as React from 'react' @@ -10,7 +11,6 @@ import { useContext } from 'use-context-selector' // import { Resolution } from '@/types/app' import { useFeatures, useFeaturesStore } from '@/app/components/base/features/hooks' import { Vision } from '@/app/components/base/icons/src/vender/features' -import Switch from '@/app/components/base/switch' import Tooltip from '@/app/components/base/tooltip' import OptionCard from '@/app/components/workflow/nodes/_base/components/option-card' import { SupportUploadFileTypes } from '@/app/components/workflow/types' diff --git a/web/app/components/app/configuration/config-vision/param-config.tsx b/web/app/components/app/configuration/config-vision/param-config.tsx index 3be4c60aa9..51092f8ae7 100644 --- a/web/app/components/app/configuration/config-vision/param-config.tsx +++ b/web/app/components/app/configuration/config-vision/param-config.tsx @@ -1,11 +1,11 @@ 'use client' import type { FC } from 'react' +import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' +import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import { RiSettings2Line } from '@remixicon/react' import { memo, useState } from 'react' import { useTranslation } from 'react-i18next' -import { Button } from '@/app/components/base/ui/button' -import { Popover, PopoverContent, PopoverTrigger } from '@/app/components/base/ui/popover' import ParamConfigContent from './param-config-content' const ParamsConfig: FC = () => { diff --git a/web/app/components/app/configuration/config/agent-setting-button.tsx b/web/app/components/app/configuration/config/agent-setting-button.tsx index 0f1021e7ce..9831660571 100644 --- a/web/app/components/app/configuration/config/agent-setting-button.tsx +++ b/web/app/components/app/configuration/config/agent-setting-button.tsx @@ -1,11 +1,11 @@ 'use client' import type { FC } from 'react' import type { AgentConfig } from '@/models/debug' +import { Button } from '@langgenius/dify-ui/button' import { RiSettings2Line } from '@remixicon/react' import * as React from 'react' import { useState } from 'react' import { useTranslation } from 'react-i18next' -import { Button } from '@/app/components/base/ui/button' import AgentSetting from './agent/agent-setting' type Props = { diff --git a/web/app/components/app/configuration/config/agent/agent-setting/__tests__/index.spec.tsx b/web/app/components/app/configuration/config/agent/agent-setting/__tests__/index.spec.tsx index cdab20df16..08a6e0f62b 100644 --- a/web/app/components/app/configuration/config/agent/agent-setting/__tests__/index.spec.tsx +++ b/web/app/components/app/configuration/config/agent/agent-setting/__tests__/index.spec.tsx @@ -12,7 +12,7 @@ vi.mock('ahooks', async (importOriginal) => { } }) -vi.mock('@/app/components/base/ui/slider', () => ({ +vi.mock('@langgenius/dify-ui/slider', () => ({ Slider: (props: { className?: string, min?: number, max?: number, value: number, onValueChange: (value: number) => void }) => ( ({ }), })) -vi.mock('@/app/components/base/ui/toast', () => ({ +vi.mock('@langgenius/dify-ui/toast', () => ({ toast: { error: (...args: unknown[]) => mockToastError(...args), }, diff --git a/web/app/components/app/configuration/config/automatic/__tests__/result.spec.tsx b/web/app/components/app/configuration/config/automatic/__tests__/result.spec.tsx index 30f41b7828..54efa4a92c 100644 --- a/web/app/components/app/configuration/config/automatic/__tests__/result.spec.tsx +++ b/web/app/components/app/configuration/config/automatic/__tests__/result.spec.tsx @@ -1,5 +1,5 @@ +import { toast } from '@langgenius/dify-ui/toast' import { fireEvent, render, screen } from '@testing-library/react' -import { toast } from '@/app/components/base/ui/toast' import Result from '../result' import { GeneratorType } from '../types' @@ -19,7 +19,7 @@ vi.mock('copy-to-clipboard', () => ({ default: (...args: unknown[]) => mockCopy(...args), })) -vi.mock('@/app/components/base/ui/toast', () => ({ +vi.mock('@langgenius/dify-ui/toast', () => ({ toast: { success: vi.fn(), }, diff --git a/web/app/components/app/configuration/config/automatic/automatic-btn.tsx b/web/app/components/app/configuration/config/automatic/automatic-btn.tsx index af14fb037d..f9e9fa362a 100644 --- a/web/app/components/app/configuration/config/automatic/automatic-btn.tsx +++ b/web/app/components/app/configuration/config/automatic/automatic-btn.tsx @@ -1,11 +1,11 @@ 'use client' import type { FC } from 'react' +import { Button } from '@langgenius/dify-ui/button' import { RiSparklingFill, } from '@remixicon/react' import * as React from 'react' import { useTranslation } from 'react-i18next' -import { Button } from '@/app/components/base/ui/button' type IAutomaticBtnProps = { onClick: () => void diff --git a/web/app/components/app/configuration/config/automatic/get-automatic-res.tsx b/web/app/components/app/configuration/config/automatic/get-automatic-res.tsx index d9200af773..aff540cecf 100644 --- a/web/app/components/app/configuration/config/automatic/get-automatic-res.tsx +++ b/web/app/components/app/configuration/config/automatic/get-automatic-res.tsx @@ -4,6 +4,17 @@ import type { FormValue } from '@/app/components/header/account-setting/model-pr // type import type { GenRes } from '@/service/debug' import type { AppModeEnum, CompletionParams, Model, ModelModeType } from '@/types/app' +import { + AlertDialog, + AlertDialogActions, + AlertDialogCancelButton, + AlertDialogConfirmButton, + AlertDialogContent, + AlertDialogDescription, + AlertDialogTitle, +} from '@langgenius/dify-ui/alert-dialog' +import { Button } from '@langgenius/dify-ui/button' +import { toast } from '@langgenius/dify-ui/toast' import { RiDatabase2Line, RiFileExcel2Line, @@ -22,17 +33,6 @@ import { useTranslation } from 'react-i18next' import { Generator } from '@/app/components/base/icons/src/vender/other' import Loading from '@/app/components/base/loading' import Modal from '@/app/components/base/modal' -import { - AlertDialog, - AlertDialogActions, - AlertDialogCancelButton, - AlertDialogConfirmButton, - AlertDialogContent, - AlertDialogDescription, - AlertDialogTitle, -} from '@/app/components/base/ui/alert-dialog' -import { Button } from '@/app/components/base/ui/button' -import { toast } from '@/app/components/base/ui/toast' import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' import { useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/components/header/account-setting/model-provider-page/hooks' diff --git a/web/app/components/app/configuration/config/automatic/result.tsx b/web/app/components/app/configuration/config/automatic/result.tsx index 4beefe8386..057fd48aeb 100644 --- a/web/app/components/app/configuration/config/automatic/result.tsx +++ b/web/app/components/app/configuration/config/automatic/result.tsx @@ -1,12 +1,12 @@ 'use client' import type { FC } from 'react' import type { GenRes } from '@/service/debug' +import { Button } from '@langgenius/dify-ui/button' +import { toast } from '@langgenius/dify-ui/toast' import { RiClipboardLine } from '@remixicon/react' import copy from 'copy-to-clipboard' import * as React from 'react' import { useTranslation } from 'react-i18next' -import { Button } from '@/app/components/base/ui/button' -import { toast } from '@/app/components/base/ui/toast' import CodeEditor from '@/app/components/workflow/nodes/llm/components/json-schema-config-modal/code-editor' import PromptRes from './prompt-res' import PromptResInWorkflow from './prompt-res-in-workflow' diff --git a/web/app/components/app/configuration/config/code-generator/__tests__/get-code-generator-res.spec.tsx b/web/app/components/app/configuration/config/code-generator/__tests__/get-code-generator-res.spec.tsx index 25039bae96..fa7e9b6bd0 100644 --- a/web/app/components/app/configuration/config/code-generator/__tests__/get-code-generator-res.spec.tsx +++ b/web/app/components/app/configuration/config/code-generator/__tests__/get-code-generator-res.spec.tsx @@ -21,7 +21,7 @@ vi.mock('react-i18next', () => ({ }), })) -vi.mock('@/app/components/base/ui/toast', () => ({ +vi.mock('@langgenius/dify-ui/toast', () => ({ toast: { error: (...args: unknown[]) => mockToastError(...args), }, diff --git a/web/app/components/app/configuration/config/code-generator/get-code-generator-res.tsx b/web/app/components/app/configuration/config/code-generator/get-code-generator-res.tsx index eb1ee7e10c..c69c0f5dae 100644 --- a/web/app/components/app/configuration/config/code-generator/get-code-generator-res.tsx +++ b/web/app/components/app/configuration/config/code-generator/get-code-generator-res.tsx @@ -3,6 +3,17 @@ import type { FormValue } from '@/app/components/header/account-setting/model-pr import type { CodeLanguage } from '@/app/components/workflow/nodes/code/types' import type { GenRes } from '@/service/debug' import type { AppModeEnum, CompletionParams, Model, ModelModeType } from '@/types/app' +import { + AlertDialog, + AlertDialogActions, + AlertDialogCancelButton, + AlertDialogConfirmButton, + AlertDialogContent, + AlertDialogDescription, + AlertDialogTitle, +} from '@langgenius/dify-ui/alert-dialog' +import { Button } from '@langgenius/dify-ui/button' +import { toast } from '@langgenius/dify-ui/toast' import { useBoolean, useSessionStorageState, @@ -13,17 +24,6 @@ import { useTranslation } from 'react-i18next' import { Generator } from '@/app/components/base/icons/src/vender/other' import Loading from '@/app/components/base/loading' import Modal from '@/app/components/base/modal' -import { - AlertDialog, - AlertDialogActions, - AlertDialogCancelButton, - AlertDialogConfirmButton, - AlertDialogContent, - AlertDialogDescription, - AlertDialogTitle, -} from '@/app/components/base/ui/alert-dialog' -import { Button } from '@/app/components/base/ui/button' -import { toast } from '@/app/components/base/ui/toast' import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' import { useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/components/header/account-setting/model-provider-page/hooks' import ModelParameterModal from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal' diff --git a/web/app/components/app/configuration/config/config-audio.tsx b/web/app/components/app/configuration/config/config-audio.tsx index 15a52610e6..25b6d64eec 100644 --- a/web/app/components/app/configuration/config/config-audio.tsx +++ b/web/app/components/app/configuration/config/config-audio.tsx @@ -1,14 +1,14 @@ 'use client' import type { FC } from 'react' +import { Switch } from '@langgenius/dify-ui/switch' import { produce } from 'immer' import * as React from 'react' import { useCallback } from 'react' import { useTranslation } from 'react-i18next' -import { useContext } from 'use-context-selector' +import { useContext } from 'use-context-selector' import { useFeatures, useFeaturesStore } from '@/app/components/base/features/hooks' import { Microphone01 } from '@/app/components/base/icons/src/vender/features' -import Switch from '@/app/components/base/switch' import Tooltip from '@/app/components/base/tooltip' import { SupportUploadFileTypes } from '@/app/components/workflow/types' import ConfigContext from '@/context/debug-configuration' diff --git a/web/app/components/app/configuration/config/config-document.tsx b/web/app/components/app/configuration/config/config-document.tsx index 859fd39426..156c605267 100644 --- a/web/app/components/app/configuration/config/config-document.tsx +++ b/web/app/components/app/configuration/config/config-document.tsx @@ -1,14 +1,14 @@ 'use client' import type { FC } from 'react' +import { Switch } from '@langgenius/dify-ui/switch' import { produce } from 'immer' import * as React from 'react' import { useCallback } from 'react' import { useTranslation } from 'react-i18next' -import { useContext } from 'use-context-selector' +import { useContext } from 'use-context-selector' import { useFeatures, useFeaturesStore } from '@/app/components/base/features/hooks' import { Document } from '@/app/components/base/icons/src/vender/features' -import Switch from '@/app/components/base/switch' import Tooltip from '@/app/components/base/tooltip' import { SupportUploadFileTypes } from '@/app/components/workflow/types' import ConfigContext from '@/context/debug-configuration' diff --git a/web/app/components/app/configuration/configuration-view.tsx b/web/app/components/app/configuration/configuration-view.tsx index 35ab462ad1..3a9882db18 100644 --- a/web/app/components/app/configuration/configuration-view.tsx +++ b/web/app/components/app/configuration/configuration-view.tsx @@ -2,6 +2,16 @@ import type { FC } from 'react' import type { ConfigurationViewModel } from './hooks/use-configuration' import { CodeBracketIcon } from '@heroicons/react/20/solid' +import { + AlertDialog, + AlertDialogActions, + AlertDialogCancelButton, + AlertDialogConfirmButton, + AlertDialogContent, + AlertDialogDescription, + AlertDialogTitle, +} from '@langgenius/dify-ui/alert-dialog' +import { Button } from '@langgenius/dify-ui/button' import * as React from 'react' import { useTranslation } from 'react-i18next' import AppPublisher from '@/app/components/app/app-publisher/features-wrapper' @@ -15,16 +25,6 @@ import Drawer from '@/app/components/base/drawer' import { FeaturesProvider } from '@/app/components/base/features' import NewFeaturePanel from '@/app/components/base/features/new-feature-panel' import Loading from '@/app/components/base/loading' -import { - AlertDialog, - AlertDialogActions, - AlertDialogCancelButton, - AlertDialogConfirmButton, - AlertDialogContent, - AlertDialogDescription, - AlertDialogTitle, -} from '@/app/components/base/ui/alert-dialog' -import { Button } from '@/app/components/base/ui/button' import ModelParameterModal from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal' import PluginDependency from '@/app/components/workflow/plugin-dependency' import ConfigContext from '@/context/debug-configuration' diff --git a/web/app/components/app/configuration/ctrl-btn-group/index.tsx b/web/app/components/app/configuration/ctrl-btn-group/index.tsx index fa8a8bdab3..6ac485c097 100644 --- a/web/app/components/app/configuration/ctrl-btn-group/index.tsx +++ b/web/app/components/app/configuration/ctrl-btn-group/index.tsx @@ -1,8 +1,8 @@ 'use client' import type { FC } from 'react' +import { Button } from '@langgenius/dify-ui/button' import * as React from 'react' import { useTranslation } from 'react-i18next' -import { Button } from '@/app/components/base/ui/button' import s from './style.module.css' type IContrlBtnGroupProps = { diff --git a/web/app/components/app/configuration/dataset-config/params-config/__tests__/config-content.spec.tsx b/web/app/components/app/configuration/dataset-config/params-config/__tests__/config-content.spec.tsx index a17b39e4b4..80b9239524 100644 --- a/web/app/components/app/configuration/dataset-config/params-config/__tests__/config-content.spec.tsx +++ b/web/app/components/app/configuration/dataset-config/params-config/__tests__/config-content.spec.tsx @@ -3,9 +3,9 @@ import type { IndexingType } from '@/app/components/datasets/create/step-two' import type { DataSet } from '@/models/datasets' import type { DatasetConfigs } from '@/models/debug' import type { RetrievalConfig } from '@/types/app' +import { toast } from '@langgenius/dify-ui/toast' import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { toast } from '@/app/components/base/ui/toast' import { useCurrentProviderAndModel, useModelListAndDefaultModelAndCurrentProviderAndModel, diff --git a/web/app/components/app/configuration/dataset-config/params-config/__tests__/index.spec.tsx b/web/app/components/app/configuration/dataset-config/params-config/__tests__/index.spec.tsx index a892de7fee..b5924a5f23 100644 --- a/web/app/components/app/configuration/dataset-config/params-config/__tests__/index.spec.tsx +++ b/web/app/components/app/configuration/dataset-config/params-config/__tests__/index.spec.tsx @@ -1,9 +1,9 @@ import type { MockedFunction, MockInstance } from 'vitest' import type { DatasetConfigs } from '@/models/debug' +import { toast } from '@langgenius/dify-ui/toast' import { render, screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import * as React from 'react' -import { toast } from '@/app/components/base/ui/toast' import { useCurrentProviderAndModel, useModelListAndDefaultModelAndCurrentProviderAndModel, diff --git a/web/app/components/app/configuration/dataset-config/params-config/config-content.tsx b/web/app/components/app/configuration/dataset-config/params-config/config-content.tsx index bb260fb499..d0e6b2fe9f 100644 --- a/web/app/components/app/configuration/dataset-config/params-config/config-content.tsx +++ b/web/app/components/app/configuration/dataset-config/params-config/config-content.tsx @@ -10,14 +10,14 @@ import type { DatasetConfigs, } from '@/models/debug' import { cn } from '@langgenius/dify-ui/cn' +import { Switch } from '@langgenius/dify-ui/switch' +import { toast } from '@langgenius/dify-ui/toast' import { memo, useCallback, useEffect, useMemo } from 'react' import { useTranslation } from 'react-i18next' import Divider from '@/app/components/base/divider' import ScoreThresholdItem from '@/app/components/base/param-item/score-threshold-item' import TopKItem from '@/app/components/base/param-item/top-k-item' -import Switch from '@/app/components/base/switch' import Tooltip from '@/app/components/base/tooltip' -import { toast } from '@/app/components/base/ui/toast' import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' import { useCurrentProviderAndModel, useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/components/header/account-setting/model-provider-page/hooks' import ModelParameterModal from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal' diff --git a/web/app/components/app/configuration/dataset-config/params-config/index.tsx b/web/app/components/app/configuration/dataset-config/params-config/index.tsx index ce78ad6e99..d4578af1d7 100644 --- a/web/app/components/app/configuration/dataset-config/params-config/index.tsx +++ b/web/app/components/app/configuration/dataset-config/params-config/index.tsx @@ -1,14 +1,14 @@ 'use client' import type { DataSet } from '@/models/datasets' import type { DatasetConfigs } from '@/models/debug' +import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' +import { toast } from '@langgenius/dify-ui/toast' import { RiEqualizer2Line } from '@remixicon/react' import { memo, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { useContext } from 'use-context-selector' import Modal from '@/app/components/base/modal' -import { Button } from '@/app/components/base/ui/button' -import { toast } from '@/app/components/base/ui/toast' import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' import { useCurrentProviderAndModel, useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/components/header/account-setting/model-provider-page/hooks' import { diff --git a/web/app/components/app/configuration/dataset-config/params-config/weighted-score.tsx b/web/app/components/app/configuration/dataset-config/params-config/weighted-score.tsx index e5080f26e4..5c2cca05d3 100644 --- a/web/app/components/app/configuration/dataset-config/params-config/weighted-score.tsx +++ b/web/app/components/app/configuration/dataset-config/params-config/weighted-score.tsx @@ -1,7 +1,7 @@ +import { Slider } from '@langgenius/dify-ui/slider' import { noop } from 'es-toolkit/function' import { memo } from 'react' import { useTranslation } from 'react-i18next' -import { Slider } from '@/app/components/base/ui/slider' const weightedScoreSliderSlotClassNames = { track: 'bg-util-colors-teal-teal-500', diff --git a/web/app/components/app/configuration/dataset-config/select-dataset/index.tsx b/web/app/components/app/configuration/dataset-config/select-dataset/index.tsx index ff371fc2df..13d11574d5 100644 --- a/web/app/components/app/configuration/dataset-config/select-dataset/index.tsx +++ b/web/app/components/app/configuration/dataset-config/select-dataset/index.tsx @@ -1,6 +1,7 @@ 'use client' import type { FC } from 'react' import type { DataSet } from '@/models/datasets' +import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { useInfiniteScroll } from 'ahooks' import * as React from 'react' @@ -10,7 +11,6 @@ import AppIcon from '@/app/components/base/app-icon' import Badge from '@/app/components/base/badge' import Loading from '@/app/components/base/loading' import Modal from '@/app/components/base/modal' -import { Button } from '@/app/components/base/ui/button' import { ModelFeatureEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' import FeatureIcon from '@/app/components/header/account-setting/model-provider-page/model-selector/feature-icon' import { useKnowledge } from '@/hooks/use-knowledge' diff --git a/web/app/components/app/configuration/dataset-config/settings-modal/__tests__/index.spec.tsx b/web/app/components/app/configuration/dataset-config/settings-modal/__tests__/index.spec.tsx index d699a4baec..cf1df79d1e 100644 --- a/web/app/components/app/configuration/dataset-config/settings-modal/__tests__/index.spec.tsx +++ b/web/app/components/app/configuration/dataset-config/settings-modal/__tests__/index.spec.tsx @@ -19,7 +19,7 @@ const toastMocks = vi.hoisted(() => ({ promise: vi.fn(), })) -vi.mock('@/app/components/base/ui/toast', () => ({ +vi.mock('@langgenius/dify-ui/toast', () => ({ toast: Object.assign(toastMocks.call, { success: vi.fn((message: string, options?: Record) => toastMocks.call({ type: 'success', message, ...options })), error: vi.fn((message: string, options?: Record) => toastMocks.call({ type: 'error', message, ...options })), diff --git a/web/app/components/app/configuration/dataset-config/settings-modal/index.tsx b/web/app/components/app/configuration/dataset-config/settings-modal/index.tsx index 872d081307..74e4ca5fe5 100644 --- a/web/app/components/app/configuration/dataset-config/settings-modal/index.tsx +++ b/web/app/components/app/configuration/dataset-config/settings-modal/index.tsx @@ -2,15 +2,15 @@ import type { FC } from 'react' import type { Member } from '@/models/common' import type { DataSet } from '@/models/datasets' import type { RetrievalConfig } from '@/types/app' +import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' +import { toast } from '@langgenius/dify-ui/toast' import { RiCloseLine } from '@remixicon/react' import { isEqual } from 'es-toolkit/predicate' import { useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import Input from '@/app/components/base/input' import Textarea from '@/app/components/base/textarea' -import { Button } from '@/app/components/base/ui/button' -import { toast } from '@/app/components/base/ui/toast' import { isReRankModelSelected } from '@/app/components/datasets/common/check-rerank-model' import { IndexingType } from '@/app/components/datasets/create/step-two' import IndexMethod from '@/app/components/datasets/settings/index-method' diff --git a/web/app/components/app/configuration/debug/__tests__/index.spec.tsx b/web/app/components/app/configuration/debug/__tests__/index.spec.tsx index 4a50ecc626..c17a74e24c 100644 --- a/web/app/components/app/configuration/debug/__tests__/index.spec.tsx +++ b/web/app/components/app/configuration/debug/__tests__/index.spec.tsx @@ -46,7 +46,7 @@ const mockState = vi.hoisted(() => ({ }, })) -vi.mock('@/app/components/base/ui/toast', () => ({ +vi.mock('@langgenius/dify-ui/toast', () => ({ toast: Object.assign(mockState.mockToastCall, { success: vi.fn((message: string, options?: Record) => mockState.mockToastCall({ type: 'success', message, ...options })), diff --git a/web/app/components/app/configuration/debug/debug-with-multiple-model/__tests__/chat-item.spec.tsx b/web/app/components/app/configuration/debug/debug-with-multiple-model/__tests__/chat-item.spec.tsx index 61eb8f2ae8..719479498a 100644 --- a/web/app/components/app/configuration/debug/debug-with-multiple-model/__tests__/chat-item.spec.tsx +++ b/web/app/components/app/configuration/debug/debug-with-multiple-model/__tests__/chat-item.spec.tsx @@ -90,7 +90,7 @@ vi.mock('@/app/components/base/chat/chat', () => ({ }, })) -vi.mock('@/app/components/base/ui/avatar', () => ({ +vi.mock('@langgenius/dify-ui/avatar', () => ({ Avatar: ({ name }: { name: string }) =>
{name}
, })) diff --git a/web/app/components/app/configuration/debug/debug-with-multiple-model/chat-item.tsx b/web/app/components/app/configuration/debug/debug-with-multiple-model/chat-item.tsx index 56345890ff..d5b353c899 100644 --- a/web/app/components/app/configuration/debug/debug-with-multiple-model/chat-item.tsx +++ b/web/app/components/app/configuration/debug/debug-with-multiple-model/chat-item.tsx @@ -2,6 +2,7 @@ import type { FC } from 'react' import type { ModelAndParameter } from '../types' import type { InputForm } from '@/app/components/base/chat/chat/type' import type { ChatConfig, OnSend } from '@/app/components/base/chat/types' +import { Avatar } from '@langgenius/dify-ui/avatar' import { memo, useCallback, @@ -11,7 +12,6 @@ import Chat from '@/app/components/base/chat/chat' import { useChat } from '@/app/components/base/chat/chat/hooks' import { getLastAnswer } from '@/app/components/base/chat/utils' import { useFeatures } from '@/app/components/base/features/hooks' -import { Avatar } from '@/app/components/base/ui/avatar' import { ModelFeatureEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' import { useAppContext } from '@/context/app-context' import { useDebugConfigurationContext } from '@/context/debug-configuration' diff --git a/web/app/components/app/configuration/debug/debug-with-multiple-model/debug-item.tsx b/web/app/components/app/configuration/debug/debug-with-multiple-model/debug-item.tsx index 2e535baeac..e76bbb0728 100644 --- a/web/app/components/app/configuration/debug/debug-with-multiple-model/debug-item.tsx +++ b/web/app/components/app/configuration/debug/debug-with-multiple-model/debug-item.tsx @@ -1,15 +1,15 @@ import type { CSSProperties, FC } from 'react' import type { ModelAndParameter } from '../types' -import { memo, useState } from 'react' -import { useTranslation } from 'react-i18next' -import ActionButton from '@/app/components/base/action-button' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, -} from '@/app/components/base/ui/dropdown-menu' +} from '@langgenius/dify-ui/dropdown-menu' +import { memo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import ActionButton from '@/app/components/base/action-button' import { ModelStatusEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' import { useDebugConfigurationContext } from '@/context/debug-configuration' import { useProviderContext } from '@/context/provider-context' diff --git a/web/app/components/app/configuration/debug/debug-with-single-model/index.tsx b/web/app/components/app/configuration/debug/debug-with-single-model/index.tsx index a9f9f1116b..aef3d5ef9c 100644 --- a/web/app/components/app/configuration/debug/debug-with-single-model/index.tsx +++ b/web/app/components/app/configuration/debug/debug-with-single-model/index.tsx @@ -1,13 +1,13 @@ import type { InputForm } from '@/app/components/base/chat/chat/type' import type { ChatConfig, ChatItem, OnSend } from '@/app/components/base/chat/types' import type { FileEntity } from '@/app/components/base/file-uploader/types' +import { Avatar } from '@langgenius/dify-ui/avatar' import { memo, useCallback, useImperativeHandle, useMemo } from 'react' import { useStore as useAppStore } from '@/app/components/app/store' import Chat from '@/app/components/base/chat/chat' import { useChat } from '@/app/components/base/chat/chat/hooks' import { getLastAnswer, isValidGeneratedAnswer } from '@/app/components/base/chat/utils' import { useFeatures } from '@/app/components/base/features/hooks' -import { Avatar } from '@/app/components/base/ui/avatar' import { ModelFeatureEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' import { useAppContext } from '@/context/app-context' import { useDebugConfigurationContext } from '@/context/debug-configuration' diff --git a/web/app/components/app/configuration/debug/index.tsx b/web/app/components/app/configuration/debug/index.tsx index 88bca7111c..87beccae24 100644 --- a/web/app/components/app/configuration/debug/index.tsx +++ b/web/app/components/app/configuration/debug/index.tsx @@ -5,6 +5,13 @@ import type { ModelAndParameter } from './types' import type { ModelParameterModalProps } from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal' import type { Inputs } from '@/models/debug' import type { ModelConfig as BackendModelConfig, VisionFile, VisionSettings } from '@/types/app' +import { Button } from '@langgenius/dify-ui/button' +import { toast } from '@langgenius/dify-ui/toast' +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@langgenius/dify-ui/tooltip' import { RiAddLine, RiEqualizer2Line, @@ -28,13 +35,6 @@ import AgentLogModal from '@/app/components/base/agent-log-modal' import { useFeatures, useFeaturesStore } from '@/app/components/base/features/hooks' import { RefreshCcw01 } from '@/app/components/base/icons/src/vender/line/arrows' import PromptLogModal from '@/app/components/base/prompt-log-modal' -import { Button } from '@/app/components/base/ui/button' -import { toast } from '@/app/components/base/ui/toast' -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from '@/app/components/base/ui/tooltip' import { ModelFeatureEnum, ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' import { useDefaultModel } from '@/app/components/header/account-setting/model-provider-page/hooks' import { DEFAULT_CHAT_PROMPT_CONFIG, DEFAULT_COMPLETION_PROMPT_CONFIG } from '@/config' diff --git a/web/app/components/app/configuration/hooks/__tests__/use-configuration-utils.spec.ts b/web/app/components/app/configuration/hooks/__tests__/use-configuration-utils.spec.ts index c0353c233d..4c72e4690c 100644 --- a/web/app/components/app/configuration/hooks/__tests__/use-configuration-utils.spec.ts +++ b/web/app/components/app/configuration/hooks/__tests__/use-configuration-utils.spec.ts @@ -52,7 +52,7 @@ vi.mock('@/app/components/workflow/nodes/knowledge-retrieval/utils', async () => } }) -vi.mock('@/app/components/base/ui/toast', () => ({ +vi.mock('@langgenius/dify-ui/toast', () => ({ toast: { error: (...args: unknown[]) => mockToastError(...args), success: (...args: unknown[]) => mockToastSuccess(...args), diff --git a/web/app/components/app/configuration/hooks/use-configuration-utils.ts b/web/app/components/app/configuration/hooks/use-configuration-utils.ts index 6911fdcd01..8a7b1c811e 100644 --- a/web/app/components/app/configuration/hooks/use-configuration-utils.ts +++ b/web/app/components/app/configuration/hooks/use-configuration-utils.ts @@ -4,9 +4,9 @@ import type { Collection } from '@/app/components/tools/types' import type { DataSet } from '@/models/datasets' import type { AnnotationReplyConfig, DatasetConfigs, ModelConfig, PromptVariable } from '@/models/debug' import type { ModelConfig as BackendModelConfig, UserInputFormItem, VisionSettings } from '@/types/app' +import { toast } from '@langgenius/dify-ui/toast' import { clone } from 'es-toolkit/object' import { produce } from 'immer' -import { toast } from '@/app/components/base/ui/toast' import { getMultipleRetrievalConfig, getSelectedDatasetsMode } from '@/app/components/workflow/nodes/knowledge-retrieval/utils' import { DEFAULT_AGENT_SETTING, DEFAULT_CHAT_PROMPT_CONFIG, DEFAULT_COMPLETION_PROMPT_CONFIG } from '@/config' import { PromptMode } from '@/models/debug' diff --git a/web/app/components/app/configuration/prompt-value-panel/index.tsx b/web/app/components/app/configuration/prompt-value-panel/index.tsx index a9df145ca1..94bc48da29 100644 --- a/web/app/components/app/configuration/prompt-value-panel/index.tsx +++ b/web/app/components/app/configuration/prompt-value-panel/index.tsx @@ -2,6 +2,7 @@ import type { FC } from 'react' import type { Inputs } from '@/models/debug' import type { VisionFile, VisionSettings } from '@/types/app' +import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { RiArrowDownSLine, @@ -19,7 +20,6 @@ import Input from '@/app/components/base/input' import Select from '@/app/components/base/select' import Textarea from '@/app/components/base/textarea' import Tooltip from '@/app/components/base/tooltip' -import { Button } from '@/app/components/base/ui/button' import BoolInput from '@/app/components/workflow/nodes/_base/components/before-run-form/bool-input' import ConfigContext from '@/context/debug-configuration' import { AppModeEnum, ModelModeType } from '@/types/app' diff --git a/web/app/components/app/configuration/tools/__tests__/external-data-tool-modal.spec.tsx b/web/app/components/app/configuration/tools/__tests__/external-data-tool-modal.spec.tsx index ceca00a9ae..2a725d88ca 100644 --- a/web/app/components/app/configuration/tools/__tests__/external-data-tool-modal.spec.tsx +++ b/web/app/components/app/configuration/tools/__tests__/external-data-tool-modal.spec.tsx @@ -11,7 +11,7 @@ vi.mock('react-i18next', () => ({ }), })) -vi.mock('@/app/components/base/ui/toast', () => ({ +vi.mock('@langgenius/dify-ui/toast', () => ({ toast: { error: (...args: unknown[]) => mockToastError(...args), }, diff --git a/web/app/components/app/configuration/tools/external-data-tool-modal.tsx b/web/app/components/app/configuration/tools/external-data-tool-modal.tsx index 68a1ee875d..b09b7b1c70 100644 --- a/web/app/components/app/configuration/tools/external-data-tool-modal.tsx +++ b/web/app/components/app/configuration/tools/external-data-tool-modal.tsx @@ -2,6 +2,10 @@ import type { FC } from 'react' import type { ExternalDataTool, } from '@/models/common' +import { Button } from '@langgenius/dify-ui/button' +import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog' +import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger, SelectValue } from '@langgenius/dify-ui/select' +import { toast } from '@langgenius/dify-ui/toast' import { noop } from 'es-toolkit/function' import { useState } from 'react' import { useTranslation } from 'react-i18next' @@ -9,10 +13,6 @@ import AppIcon from '@/app/components/base/app-icon' import EmojiPicker from '@/app/components/base/emoji-picker' import FormGeneration from '@/app/components/base/features/new-feature-panel/moderation/form-generation' import { BookOpen01 } from '@/app/components/base/icons/src/vender/line/education' -import { Button } from '@/app/components/base/ui/button' -import { Dialog, DialogContent } from '@/app/components/base/ui/dialog' -import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger, SelectValue } from '@/app/components/base/ui/select' -import { toast } from '@/app/components/base/ui/toast' import ApiBasedExtensionSelector from '@/app/components/header/account-setting/api-based-extension-page/selector' import { useDocLink, useLocale } from '@/context/i18n' import { useCodeBasedExtensions } from '@/service/use-common' diff --git a/web/app/components/app/create-app-dialog/app-card/index.tsx b/web/app/components/app/create-app-dialog/app-card/index.tsx index fef7199ca2..65bd74344a 100644 --- a/web/app/components/app/create-app-dialog/app-card/index.tsx +++ b/web/app/components/app/create-app-dialog/app-card/index.tsx @@ -1,6 +1,7 @@ 'use client' import type { App } from '@/models/explore' import { PlusIcon } from '@heroicons/react/20/solid' +import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { RiInformation2Line } from '@remixicon/react' import { useCallback } from 'react' @@ -8,7 +9,6 @@ import { useTranslation } from 'react-i18next' import { useContextSelector } from 'use-context-selector' import { trackEvent } from '@/app/components/base/amplitude' import AppIcon from '@/app/components/base/app-icon' -import { Button } from '@/app/components/base/ui/button' import AppListContext from '@/context/app-list-context' import { useGlobalPublicStore } from '@/context/global-public-context' import { AppTypeIcon, AppTypeLabel } from '../../type-selector' diff --git a/web/app/components/app/create-app-dialog/app-list/__tests__/index.spec.tsx b/web/app/components/app/create-app-dialog/app-list/__tests__/index.spec.tsx index 34024da54c..0c6462c2f9 100644 --- a/web/app/components/app/create-app-dialog/app-list/__tests__/index.spec.tsx +++ b/web/app/components/app/create-app-dialog/app-list/__tests__/index.spec.tsx @@ -86,7 +86,7 @@ vi.mock('@/app/components/explore/create-app-modal', () => ({ ) : null, })) -vi.mock('@/app/components/base/ui/toast', () => ({ +vi.mock('@langgenius/dify-ui/toast', () => ({ toast: { success: (...args: unknown[]) => mockToastSuccess(...args), error: (...args: unknown[]) => mockToastError(...args), diff --git a/web/app/components/app/create-app-dialog/app-list/index.tsx b/web/app/components/app/create-app-dialog/app-list/index.tsx index 19089d6364..1924de3893 100644 --- a/web/app/components/app/create-app-dialog/app-list/index.tsx +++ b/web/app/components/app/create-app-dialog/app-list/index.tsx @@ -3,6 +3,7 @@ import type { CreateAppModalProps } from '@/app/components/explore/create-app-modal' import type { App } from '@/models/explore' import { cn } from '@langgenius/dify-ui/cn' +import { toast } from '@langgenius/dify-ui/toast' import { RiRobot2Line } from '@remixicon/react' import { useDebounceFn } from 'ahooks' import * as React from 'react' @@ -12,7 +13,6 @@ import AppTypeSelector from '@/app/components/app/type-selector' import Divider from '@/app/components/base/divider' import Input from '@/app/components/base/input' import Loading from '@/app/components/base/loading' -import { toast } from '@/app/components/base/ui/toast' import CreateAppModal from '@/app/components/explore/create-app-modal' import { usePluginDependencies } from '@/app/components/workflow/plugin-dependency/hooks' import { NEED_REFRESH_APP_LIST_KEY } from '@/config' diff --git a/web/app/components/app/create-app-modal/__tests__/index.spec.tsx b/web/app/components/app/create-app-modal/__tests__/index.spec.tsx index 305b90981b..24fb21747f 100644 --- a/web/app/components/app/create-app-modal/__tests__/index.spec.tsx +++ b/web/app/components/app/create-app-modal/__tests__/index.spec.tsx @@ -41,7 +41,7 @@ const toastMocks = vi.hoisted(() => ({ mockToastSuccess: vi.fn(), mockToastError: vi.fn(), })) -vi.mock('@/app/components/base/ui/toast', () => ({ +vi.mock('@langgenius/dify-ui/toast', () => ({ toast: { success: toastMocks.mockToastSuccess, error: toastMocks.mockToastError, diff --git a/web/app/components/app/create-app-modal/index.tsx b/web/app/components/app/create-app-modal/index.tsx index 073107755f..2ff8a0aacd 100644 --- a/web/app/components/app/create-app-modal/index.tsx +++ b/web/app/components/app/create-app-modal/index.tsx @@ -1,8 +1,10 @@ 'use client' import type { AppIconSelection } from '../../base/app-icon-picker' -import { cn } from '@langgenius/dify-ui/cn' +import { Button } from '@langgenius/dify-ui/button' +import { cn } from '@langgenius/dify-ui/cn' +import { toast } from '@langgenius/dify-ui/toast' import { RiArrowRightLine, RiArrowRightSLine, RiExchange2Fill } from '@remixicon/react' import { useDebounceFn, useKeyPress } from 'ahooks' import { useCallback, useEffect, useRef, useState } from 'react' @@ -13,8 +15,6 @@ import FullScreenModal from '@/app/components/base/fullscreen-modal' import { BubbleTextMod, ChatBot, ListSparkle, Logic } from '@/app/components/base/icons/src/vender/solid/communication' import Input from '@/app/components/base/input' import Textarea from '@/app/components/base/textarea' -import { Button } from '@/app/components/base/ui/button' -import { toast } from '@/app/components/base/ui/toast' import AppsFull from '@/app/components/billing/apps-full-in-dialog' import { NEED_REFRESH_APP_LIST_KEY } from '@/config' import { useAppContext } from '@/context/app-context' diff --git a/web/app/components/app/create-from-dsl-modal/__tests__/index.spec.tsx b/web/app/components/app/create-from-dsl-modal/__tests__/index.spec.tsx index ae555a872b..f56b815399 100644 --- a/web/app/components/app/create-from-dsl-modal/__tests__/index.spec.tsx +++ b/web/app/components/app/create-from-dsl-modal/__tests__/index.spec.tsx @@ -83,7 +83,7 @@ vi.mock('@/utils/app-redirection', () => ({ getRedirection: (...args: unknown[]) => mockGetRedirection(...args), })) -vi.mock('@/app/components/base/ui/toast', () => ({ +vi.mock('@langgenius/dify-ui/toast', () => ({ toast: Object.assign( (...args: unknown[]) => toastMocks.call(...args), { diff --git a/web/app/components/app/create-from-dsl-modal/__tests__/uploader.spec.tsx b/web/app/components/app/create-from-dsl-modal/__tests__/uploader.spec.tsx index 8b935db118..ba097b355c 100644 --- a/web/app/components/app/create-from-dsl-modal/__tests__/uploader.spec.tsx +++ b/web/app/components/app/create-from-dsl-modal/__tests__/uploader.spec.tsx @@ -1,5 +1,5 @@ +import { toast } from '@langgenius/dify-ui/toast' import { fireEvent, render, screen } from '@testing-library/react' -import { toast } from '@/app/components/base/ui/toast' import Uploader from '../uploader' vi.mock('react-i18next', () => ({ @@ -8,7 +8,7 @@ vi.mock('react-i18next', () => ({ }), })) -vi.mock('@/app/components/base/ui/toast', () => ({ +vi.mock('@langgenius/dify-ui/toast', () => ({ toast: { error: vi.fn(), }, diff --git a/web/app/components/app/create-from-dsl-modal/dsl-confirm-modal.tsx b/web/app/components/app/create-from-dsl-modal/dsl-confirm-modal.tsx index 59ba840a07..d0c97e185c 100644 --- a/web/app/components/app/create-from-dsl-modal/dsl-confirm-modal.tsx +++ b/web/app/components/app/create-from-dsl-modal/dsl-confirm-modal.tsx @@ -1,6 +1,6 @@ +import { Button } from '@langgenius/dify-ui/button' import { useTranslation } from 'react-i18next' import Modal from '@/app/components/base/modal' -import { Button } from '@/app/components/base/ui/button' type DSLConfirmModalProps = { versions?: { diff --git a/web/app/components/app/create-from-dsl-modal/index.tsx b/web/app/components/app/create-from-dsl-modal/index.tsx index d4b857ee6b..4f99fe9027 100644 --- a/web/app/components/app/create-from-dsl-modal/index.tsx +++ b/web/app/components/app/create-from-dsl-modal/index.tsx @@ -1,7 +1,9 @@ 'use client' import type { MouseEventHandler } from 'react' +import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' +import { toast } from '@langgenius/dify-ui/toast' import { RiCloseLine } from '@remixicon/react' import { useDebounceFn, useKeyPress } from 'ahooks' import { noop } from 'es-toolkit/function' @@ -9,8 +11,6 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import Input from '@/app/components/base/input' import Modal from '@/app/components/base/modal' -import { Button } from '@/app/components/base/ui/button' -import { toast } from '@/app/components/base/ui/toast' import AppsFull from '@/app/components/billing/apps-full-in-dialog' import { usePluginDependencies } from '@/app/components/workflow/plugin-dependency/hooks' import { NEED_REFRESH_APP_LIST_KEY } from '@/config' diff --git a/web/app/components/app/create-from-dsl-modal/uploader.tsx b/web/app/components/app/create-from-dsl-modal/uploader.tsx index 1daa7e9f7b..aeecb280d0 100644 --- a/web/app/components/app/create-from-dsl-modal/uploader.tsx +++ b/web/app/components/app/create-from-dsl-modal/uploader.tsx @@ -1,6 +1,7 @@ 'use client' import type { FC } from 'react' import { cn } from '@langgenius/dify-ui/cn' +import { toast } from '@langgenius/dify-ui/toast' import { RiDeleteBinLine, RiUploadCloud2Line, @@ -10,7 +11,6 @@ import { useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import ActionButton from '@/app/components/base/action-button' import { Yaml as YamlIcon } from '@/app/components/base/icons/src/public/files' -import { toast } from '@/app/components/base/ui/toast' import { formatFileSize } from '@/utils/format' type Props = { diff --git a/web/app/components/app/duplicate-modal/__tests__/index.spec.tsx b/web/app/components/app/duplicate-modal/__tests__/index.spec.tsx index a2ff6bc2fd..ceec1aa3c0 100644 --- a/web/app/components/app/duplicate-modal/__tests__/index.spec.tsx +++ b/web/app/components/app/duplicate-modal/__tests__/index.spec.tsx @@ -20,7 +20,7 @@ vi.mock('@/context/provider-context', () => ({ }), })) -vi.mock('@/app/components/base/ui/toast', () => ({ +vi.mock('@langgenius/dify-ui/toast', () => ({ toast: { error: (...args: unknown[]) => toastErrorMock(...args), }, diff --git a/web/app/components/app/duplicate-modal/index.tsx b/web/app/components/app/duplicate-modal/index.tsx index c28ead7821..e55ac6cf66 100644 --- a/web/app/components/app/duplicate-modal/index.tsx +++ b/web/app/components/app/duplicate-modal/index.tsx @@ -1,6 +1,8 @@ 'use client' import type { AppIconType } from '@/types/app' +import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' +import { toast } from '@langgenius/dify-ui/toast' import { RiCloseLine } from '@remixicon/react' import { noop } from 'es-toolkit/function' import * as React from 'react' @@ -9,8 +11,6 @@ import { useTranslation } from 'react-i18next' import AppIcon from '@/app/components/base/app-icon' import Input from '@/app/components/base/input' import Modal from '@/app/components/base/modal' -import { Button } from '@/app/components/base/ui/button' -import { toast } from '@/app/components/base/ui/toast' import AppsFull from '@/app/components/billing/apps-full-in-dialog' import { useProviderContext } from '@/context/provider-context' import AppIconPicker from '../../base/app-icon-picker' diff --git a/web/app/components/app/in-site-message/index.tsx b/web/app/components/app/in-site-message/index.tsx index 5482086483..4038fb375d 100644 --- a/web/app/components/app/in-site-message/index.tsx +++ b/web/app/components/app/in-site-message/index.tsx @@ -1,10 +1,10 @@ 'use client' +import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { useEffect, useMemo, useState } from 'react' import { trackEvent } from '@/app/components/base/amplitude' import { MarkdownWithDirective } from '@/app/components/base/markdown-with-directive' -import { Button } from '@/app/components/base/ui/button' type InSiteMessageAction = 'link' | 'close' type InSiteMessageButtonType = 'primary' | 'default' diff --git a/web/app/components/app/log/__tests__/model-info.spec.tsx b/web/app/components/app/log/__tests__/model-info.spec.tsx index f41aaf4c00..44dcffe7bf 100644 --- a/web/app/components/app/log/__tests__/model-info.spec.tsx +++ b/web/app/components/app/log/__tests__/model-info.spec.tsx @@ -34,7 +34,7 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/model-name' ), })) -vi.mock('@/app/components/base/ui/popover', async () => { +vi.mock('@langgenius/dify-ui/popover', async () => { const React = await import('react') const PopoverContext = React.createContext<{ open: boolean, onOpenChange?: (open: boolean) => void } | null>(null) diff --git a/web/app/components/app/log/list.tsx b/web/app/components/app/log/list.tsx index 551bae0212..da96d41804 100644 --- a/web/app/components/app/log/list.tsx +++ b/web/app/components/app/log/list.tsx @@ -9,6 +9,7 @@ import { HandThumbUpIcon, } from '@heroicons/react/24/outline' import { cn } from '@langgenius/dify-ui/cn' +import { toast } from '@langgenius/dify-ui/toast' import { RiCloseLine, RiEditFill } from '@remixicon/react' import dayjs from 'dayjs' import timezone from 'dayjs/plugin/timezone' @@ -30,7 +31,6 @@ import Drawer from '@/app/components/base/drawer' import Loading from '@/app/components/base/loading' import MessageLogModal from '@/app/components/base/message-log-modal' import Tooltip from '@/app/components/base/tooltip' -import { toast } from '@/app/components/base/ui/toast' import { WorkflowContextProvider } from '@/app/components/workflow/context' import { useAppContext } from '@/context/app-context' import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' diff --git a/web/app/components/app/log/model-info.tsx b/web/app/components/app/log/model-info.tsx index e768f30a50..314627d855 100644 --- a/web/app/components/app/log/model-info.tsx +++ b/web/app/components/app/log/model-info.tsx @@ -1,12 +1,12 @@ 'use client' import type { FC } from 'react' import { cn } from '@langgenius/dify-ui/cn' +import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import { RiInformation2Line, } from '@remixicon/react' import * as React from 'react' import { useTranslation } from 'react-i18next' -import { Popover, PopoverContent, PopoverTrigger } from '@/app/components/base/ui/popover' import { useTextGenerationCurrentProviderAndModelAndModelList } from '@/app/components/header/account-setting/model-provider-page/hooks' import ModelIcon from '@/app/components/header/account-setting/model-provider-page/model-icon' import ModelName from '@/app/components/header/account-setting/model-provider-page/model-name' diff --git a/web/app/components/app/overview/__tests__/trigger-card.spec.tsx b/web/app/components/app/overview/__tests__/trigger-card.spec.tsx index d51a23bb05..7e183a3b15 100644 --- a/web/app/components/app/overview/__tests__/trigger-card.spec.tsx +++ b/web/app/components/app/overview/__tests__/trigger-card.spec.tsx @@ -73,8 +73,8 @@ vi.mock('@/app/components/workflow/block-icon', () => ({ ), })) -vi.mock('@/app/components/base/switch', () => ({ - default: ({ checked, onCheckedChange, disabled }: { checked: boolean, onCheckedChange: (v: boolean) => void, disabled: boolean }) => ( +vi.mock('@langgenius/dify-ui/switch', () => ({ + Switch: ({ checked, onCheckedChange, disabled }: { checked: boolean, onCheckedChange: (v: boolean) => void, disabled: boolean }) => ( , })) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/base/header.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/base/header.tsx index 034556d96f..a285946272 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/base/header.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/base/header.tsx @@ -1,10 +1,10 @@ import type { CredentialSelectorProps } from './credential-selector' +import { Button } from '@langgenius/dify-ui/button' import { RiBookOpenLine, RiEqualizer2Line } from '@remixicon/react' import * as React from 'react' import { useTranslation } from 'react-i18next' import Divider from '@/app/components/base/divider' import Tooltip from '@/app/components/base/tooltip' -import { Button } from '@/app/components/base/ui/button' import CredentialSelector from './credential-selector' type HeaderProps = { diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/hooks/__tests__/use-local-file-upload.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/hooks/__tests__/use-local-file-upload.spec.tsx index 80a8fc854d..dc20688e9e 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/hooks/__tests__/use-local-file-upload.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/hooks/__tests__/use-local-file-upload.spec.tsx @@ -18,7 +18,7 @@ const { mockNotify, mockToast } = vi.hoisted(() => { return { mockNotify, mockToast } }) -vi.mock('@/app/components/base/ui/toast', () => ({ +vi.mock('@langgenius/dify-ui/toast', () => ({ toast: mockToast, })) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/__tests__/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/__tests__/index.spec.tsx index 6be0e28d31..c193638a6a 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/__tests__/index.spec.tsx @@ -37,8 +37,8 @@ const { mockToastError } = vi.hoisted(() => ({ mockToastError: vi.fn(), })) -vi.mock('@/app/components/base/ui/toast', async (importOriginal) => { - const actual = await importOriginal() +vi.mock('@langgenius/dify-ui/toast', async (importOriginal) => { + const actual = await importOriginal() return { ...actual, toast: { diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/index.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/index.tsx index 5321986cd7..22bc8a65e0 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/index.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/index.tsx @@ -1,11 +1,11 @@ import type { DataSourceNodeType } from '@/app/components/workflow/nodes/data-source/types' import type { DataSourceNotionPageMap, DataSourceNotionWorkspace } from '@/models/common' import type { DataSourceNodeCompletedResponse, DataSourceNodeErrorResponse } from '@/types/pipeline' +import { toast } from '@langgenius/dify-ui/toast' import { useCallback, useEffect, useMemo } from 'react' import { useShallow } from 'zustand/react/shallow' import Loading from '@/app/components/base/loading' import SearchInput from '@/app/components/base/notion-page-selector/search-input' -import { toast } from '@/app/components/base/ui/toast' import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail' import { useDocLink } from '@/context/i18n' diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/__tests__/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/__tests__/index.spec.tsx index 6eed119ede..c8fdf49fd1 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/__tests__/index.spec.tsx @@ -49,8 +49,8 @@ const { mockToastError } = vi.hoisted(() => ({ mockToastError: vi.fn(), })) -vi.mock('@/app/components/base/ui/toast', async (importOriginal) => { - const actual = await importOriginal() +vi.mock('@langgenius/dify-ui/toast', async (importOriginal) => { + const actual = await importOriginal() return { ...actual, toast: { diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/connect/index.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/connect/index.tsx index 5b1b0a6b1a..6a7190161d 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/connect/index.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/connect/index.tsx @@ -1,7 +1,7 @@ import type { DataSourceNodeType } from '@/app/components/workflow/nodes/data-source/types' +import { Button } from '@langgenius/dify-ui/button' import { useTranslation } from 'react-i18next' import { Icon3Dots } from '@/app/components/base/icons/src/vender/line/others' -import { Button } from '@/app/components/base/ui/button' import BlockIcon from '@/app/components/workflow/block-icon' import { useToolIcon } from '@/app/components/workflow/hooks' import { BlockEnum } from '@/app/components/workflow/types' diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/dropdown/index.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/dropdown/index.tsx index c6a90824ab..43b5fcc71a 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/dropdown/index.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/dropdown/index.tsx @@ -1,11 +1,11 @@ import { cn } from '@langgenius/dify-ui/cn' -import * as React from 'react' -import { useCallback, useState } from 'react' import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger, -} from '@/app/components/base/ui/dropdown-menu' +} from '@langgenius/dify-ui/dropdown-menu' +import * as React from 'react' +import { useCallback, useState } from 'react' import Menu from './menu' type DropdownProps = { diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/empty-search-result.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/empty-search-result.tsx index ca110d3694..1691bf90b2 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/empty-search-result.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/empty-search-result.tsx @@ -1,7 +1,7 @@ +import { Button } from '@langgenius/dify-ui/button' import * as React from 'react' import { useTranslation } from 'react-i18next' import { SearchMenu } from '@/app/components/base/icons/src/vender/knowledge' -import { Button } from '@/app/components/base/ui/button' type EmptySearchResultProps = { onResetKeywords: () => void diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/header.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/header.tsx index 5c12aaa68c..bc51751aef 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/header.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/header.tsx @@ -1,7 +1,7 @@ +import { Button } from '@langgenius/dify-ui/button' import { RiBookOpenLine, RiEqualizer2Line } from '@remixicon/react' import * as React from 'react' import Divider from '@/app/components/base/divider' -import { Button } from '@/app/components/base/ui/button' type HeaderProps = { onClickConfiguration?: () => void diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/index.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/index.tsx index 2113e8841c..76614e3865 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/index.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/index.tsx @@ -1,10 +1,10 @@ import type { DataSourceNodeType } from '@/app/components/workflow/nodes/data-source/types' import type { OnlineDriveFile } from '@/models/pipeline' import type { DataSourceNodeCompletedResponse, DataSourceNodeErrorResponse } from '@/types/pipeline' +import { toast } from '@langgenius/dify-ui/toast' import { produce } from 'immer' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useShallow } from 'zustand/react/shallow' -import { toast } from '@/app/components/base/ui/toast' import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail' import { useDocLink } from '@/context/i18n' diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/__tests__/crawled-result-item.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/__tests__/crawled-result-item.spec.tsx index 62dba84e30..cded02b431 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/__tests__/crawled-result-item.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/__tests__/crawled-result-item.spec.tsx @@ -3,7 +3,7 @@ import { render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import CrawledResultItem from '../crawled-result-item' -vi.mock('@/app/components/base/ui/button', () => ({ +vi.mock('@langgenius/dify-ui/button', () => ({ Button: ({ children, onClick }: { children: React.ReactNode, onClick: () => void }) => ( ), diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/crawled-result-item.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/crawled-result-item.tsx index 664a251e25..19019b4dd7 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/crawled-result-item.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/crawled-result-item.tsx @@ -1,12 +1,12 @@ 'use client' import type { CrawlResultItem as CrawlResultItemType } from '@/models/datasets' +import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import * as React from 'react' import { useCallback } from 'react' import { useTranslation } from 'react-i18next' import Checkbox from '@/app/components/base/checkbox' import Radio from '@/app/components/base/radio/ui' -import { Button } from '@/app/components/base/ui/button' type CrawledResultItemProps = { payload: CrawlResultItemType diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/options/__tests__/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/options/__tests__/index.spec.tsx index cea569fa5f..2ab8ad6d4b 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/options/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/options/__tests__/index.spec.tsx @@ -10,8 +10,8 @@ const { mockToastError } = vi.hoisted(() => ({ mockToastError: vi.fn(), })) -vi.mock('@/app/components/base/ui/toast', async (importOriginal) => { - const actual = await importOriginal() +vi.mock('@langgenius/dify-ui/toast', async (importOriginal) => { + const actual = await importOriginal() return { ...actual, toast: { diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/options/index.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/options/index.tsx index c8a06ea807..46c6c6f462 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/options/index.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/options/index.tsx @@ -1,5 +1,7 @@ import type { RAGPipelineVariables } from '@/models/pipeline' +import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' +import { toast } from '@langgenius/dify-ui/toast' import { RiPlayLargeLine } from '@remixicon/react' import { useBoolean } from 'ahooks' import { useEffect, useMemo } from 'react' @@ -8,8 +10,6 @@ import { useAppForm } from '@/app/components/base/form' import BaseField from '@/app/components/base/form/form-scenarios/base/field' import { generateZodSchema } from '@/app/components/base/form/form-scenarios/base/utils' import { ArrowDownRoundFill } from '@/app/components/base/icons/src/vender/solid/general' -import { Button } from '@/app/components/base/ui/button' -import { toast } from '@/app/components/base/ui/toast' import { useConfigurations, useInitialData } from '@/app/components/rag-pipeline/hooks/use-input-fields' import { CrawlStep } from '@/models/datasets' diff --git a/web/app/components/datasets/documents/create-from-pipeline/left-header.tsx b/web/app/components/datasets/documents/create-from-pipeline/left-header.tsx index 925da57197..ab432d6962 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/left-header.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/left-header.tsx @@ -1,8 +1,8 @@ import type { Step } from './step-indicator' +import { Button } from '@langgenius/dify-ui/button' import { RiArrowLeftLine } from '@remixicon/react' import * as React from 'react' import Effect from '@/app/components/base/effect' -import { Button } from '@/app/components/base/ui/button' import Link from '@/next/link' import { useParams } from '@/next/navigation' import StepIndicator from './step-indicator' diff --git a/web/app/components/datasets/documents/create-from-pipeline/preview/__tests__/online-document-preview.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/preview/__tests__/online-document-preview.spec.tsx index 1e094fedb0..96033a6f60 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/preview/__tests__/online-document-preview.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/preview/__tests__/online-document-preview.spec.tsx @@ -9,8 +9,8 @@ const { mockToastError } = vi.hoisted(() => ({ mockToastError: vi.fn(), })) -vi.mock('@/app/components/base/ui/toast', async (importOriginal) => { - const actual = await importOriginal() +vi.mock('@langgenius/dify-ui/toast', async (importOriginal) => { + const actual = await importOriginal() return { ...actual, toast: { diff --git a/web/app/components/datasets/documents/create-from-pipeline/preview/chunk-preview.tsx b/web/app/components/datasets/documents/create-from-pipeline/preview/chunk-preview.tsx index 2d729ee079..9b56c6c8a3 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/preview/chunk-preview.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/preview/chunk-preview.tsx @@ -1,13 +1,13 @@ import type { NotionPage } from '@/models/common' import type { CrawlResultItem, CustomFile, DocumentItem, FileIndexingEstimateResponse } from '@/models/datasets' import type { OnlineDriveFile } from '@/models/pipeline' +import { Button } from '@langgenius/dify-ui/button' import { RiSearchEyeLine } from '@remixicon/react' import * as React from 'react' import { useState } from 'react' import { useTranslation } from 'react-i18next' import Badge from '@/app/components/base/badge' import { SkeletonContainer, SkeletonPoint, SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton' -import { Button } from '@/app/components/base/ui/button' import SummaryLabel from '@/app/components/datasets/documents/detail/completed/common/summary-label' import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail' import { ChunkingMode } from '@/models/datasets' diff --git a/web/app/components/datasets/documents/create-from-pipeline/preview/online-document-preview.tsx b/web/app/components/datasets/documents/create-from-pipeline/preview/online-document-preview.tsx index 3e9b409a70..793ba1f22b 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/preview/online-document-preview.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/preview/online-document-preview.tsx @@ -1,12 +1,12 @@ 'use client' import type { NotionPage } from '@/models/common' +import { toast } from '@langgenius/dify-ui/toast' import { RiCloseLine } from '@remixicon/react' import * as React from 'react' import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { Notion } from '@/app/components/base/icons/src/public/common' import { Markdown } from '@/app/components/base/markdown' -import { toast } from '@/app/components/base/ui/toast' import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail' import { usePreviewOnlineDocument } from '@/service/use-pipeline' import { formatNumberAbbreviated } from '@/utils/format' diff --git a/web/app/components/datasets/documents/create-from-pipeline/process-documents/__tests__/components.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/process-documents/__tests__/components.spec.tsx index ff5f8afa66..16b6ef1373 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/process-documents/__tests__/components.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/process-documents/__tests__/components.spec.tsx @@ -11,8 +11,8 @@ const { mockToastError } = vi.hoisted(() => ({ mockToastError: vi.fn(), })) -vi.mock('@/app/components/base/ui/toast', async (importOriginal) => { - const actual = await importOriginal() +vi.mock('@langgenius/dify-ui/toast', async (importOriginal) => { + const actual = await importOriginal() return { ...actual, toast: { diff --git a/web/app/components/datasets/documents/create-from-pipeline/process-documents/__tests__/form.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/process-documents/__tests__/form.spec.tsx index 09f28fc5da..dc54ba2757 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/process-documents/__tests__/form.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/process-documents/__tests__/form.spec.tsx @@ -8,8 +8,8 @@ const { mockToastError } = vi.hoisted(() => ({ mockToastError: vi.fn(), })) -vi.mock('@/app/components/base/ui/toast', async (importOriginal) => { - const actual = await importOriginal() +vi.mock('@langgenius/dify-ui/toast', async (importOriginal) => { + const actual = await importOriginal() return { ...actual, toast: { diff --git a/web/app/components/datasets/documents/create-from-pipeline/process-documents/__tests__/header.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/process-documents/__tests__/header.spec.tsx index 7e9eabaeda..431fa76f2c 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/process-documents/__tests__/header.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/process-documents/__tests__/header.spec.tsx @@ -2,7 +2,7 @@ import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import Header from '../header' -vi.mock('@/app/components/base/ui/button', () => ({ +vi.mock('@langgenius/dify-ui/button', () => ({ Button: ({ children, onClick, disabled, variant }: { children: React.ReactNode, onClick: () => void, disabled?: boolean, variant: string }) => ( , })) -vi.mock('@/app/components/base/ui/alert-dialog', () => ({ +vi.mock('@langgenius/dify-ui/alert-dialog', () => ({ AlertDialog: ({ children, onOpenChange }: AlertDialogProps) => { latestAlertDialogOnOpenChange = onOpenChange return
{children}
diff --git a/web/app/components/header/account-setting/model-provider-page/model-modal/index.tsx b/web/app/components/header/account-setting/model-provider-page/model-modal/index.tsx index 995da8bb98..a8f004f3a2 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-modal/index.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-modal/index.tsx @@ -9,6 +9,20 @@ import type { FormRefObject, FormSchema, } from '@/app/components/base/form/types' +import { + AlertDialog, + AlertDialogActions, + AlertDialogCancelButton, + AlertDialogConfirmButton, + AlertDialogContent, + AlertDialogTitle, +} from '@langgenius/dify-ui/alert-dialog' +import { Button } from '@langgenius/dify-ui/button' +import { + Dialog, + DialogCloseButton, + DialogContent, +} from '@langgenius/dify-ui/dialog' import { memo, useCallback, @@ -22,20 +36,6 @@ import AuthForm from '@/app/components/base/form/form-scenarios/auth' import { LinkExternal02 } from '@/app/components/base/icons/src/vender/line/general' import { Lock01 } from '@/app/components/base/icons/src/vender/solid/security' import Loading from '@/app/components/base/loading' -import { - AlertDialog, - AlertDialogActions, - AlertDialogCancelButton, - AlertDialogConfirmButton, - AlertDialogContent, - AlertDialogTitle, -} from '@/app/components/base/ui/alert-dialog' -import { Button } from '@/app/components/base/ui/button' -import { - Dialog, - DialogCloseButton, - DialogContent, -} from '@/app/components/base/ui/dialog' import { useAuth, useCredentialData, diff --git a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/__tests__/parameter-item.select.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/__tests__/parameter-item.select.spec.tsx index ae4a68bad7..c221071b82 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/__tests__/parameter-item.select.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/__tests__/parameter-item.select.spec.tsx @@ -6,8 +6,8 @@ vi.mock('../../hooks', () => ({ useLanguage: () => 'en_US', })) -vi.mock('@/app/components/base/ui/select', async (importOriginal) => { - const actual = await importOriginal() +vi.mock('@langgenius/dify-ui/select', async (importOriginal) => { + const actual = await importOriginal() return { ...actual, diff --git a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/__tests__/parameter-item.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/__tests__/parameter-item.spec.tsx index 2e928c36cc..a57884df1d 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/__tests__/parameter-item.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/__tests__/parameter-item.spec.tsx @@ -11,7 +11,7 @@ vi.mock('../../hooks', () => ({ useLanguage: () => 'en_US', })) -vi.mock('@/app/components/base/ui/slider', () => ({ +vi.mock('@langgenius/dify-ui/slider', () => ({ Slider: ({ onValueChange }: { onValueChange: (v: number) => void }) => ( ), diff --git a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/configuration-button.tsx b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/configuration-button.tsx index 7545cf6a22..598db04ee9 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/configuration-button.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/configuration-button.tsx @@ -1,5 +1,5 @@ +import { Button } from '@langgenius/dify-ui/button' import { useTranslation } from 'react-i18next' -import { Button } from '@/app/components/base/ui/button' import { ConfigurationMethodEnum } from '../declarations' type ConfigurationButtonProps = { diff --git a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/index.tsx b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/index.tsx index 38b343bc19..6f37775052 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/index.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/index.tsx @@ -14,16 +14,16 @@ import type { NodeOutPutVar, } from '@/app/components/workflow/types' import { cn } from '@langgenius/dify-ui/cn' -import { useMemo, useRef, useState } from 'react' -import { useTranslation } from 'react-i18next' -import { ArrowNarrowLeft } from '@/app/components/base/icons/src/vender/line/arrows' -import Loading from '@/app/components/base/loading' import { Popover, PopoverClose, PopoverContent, PopoverTrigger, -} from '@/app/components/base/ui/popover' +} from '@langgenius/dify-ui/popover' +import { useMemo, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { ArrowNarrowLeft } from '@/app/components/base/icons/src/vender/line/arrows' +import Loading from '@/app/components/base/loading' import { PROVIDER_WITH_PRESET_TONE, STOP_PARAMETER_RULE, TONE_LIST } from '@/config' import { useModelParameterRules } from '@/service/use-common' import { diff --git a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/parameter-item.tsx b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/parameter-item.tsx index 4cda97031f..f7e1962fd7 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/parameter-item.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/parameter-item.tsx @@ -4,15 +4,15 @@ import type { NodeOutPutVar, } from '@/app/components/workflow/types' import { cn } from '@langgenius/dify-ui/cn' +import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger, SelectValue } from '@langgenius/dify-ui/select' +import { Slider } from '@langgenius/dify-ui/slider' +import { Switch } from '@langgenius/dify-ui/switch' +import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import PromptEditor from '@/app/components/base/prompt-editor' import Radio from '@/app/components/base/radio' -import Switch from '@/app/components/base/switch' import TagInput from '@/app/components/base/tag-input' -import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger, SelectValue } from '@/app/components/base/ui/select' -import { Slider } from '@/app/components/base/ui/slider' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/app/components/base/ui/tooltip' import { BlockEnum } from '@/app/components/workflow/types' import { useLanguage } from '../hooks' import { isNullOrUndefined } from '../utils' diff --git a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/presets-parameter.tsx b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/presets-parameter.tsx index c2138c1c6f..cb537ab18f 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/presets-parameter.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/presets-parameter.tsx @@ -1,15 +1,15 @@ import type { ReactNode } from 'react' -import { useTranslation } from 'react-i18next' -import { Brush01 } from '@/app/components/base/icons/src/vender/solid/editor' -import { Scales02 } from '@/app/components/base/icons/src/vender/solid/FinanceAndECommerce' -import { Target04 } from '@/app/components/base/icons/src/vender/solid/general' -import { Button } from '@/app/components/base/ui/button' +import { Button } from '@langgenius/dify-ui/button' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, -} from '@/app/components/base/ui/dropdown-menu' +} from '@langgenius/dify-ui/dropdown-menu' +import { useTranslation } from 'react-i18next' +import { Brush01 } from '@/app/components/base/icons/src/vender/solid/editor' +import { Scales02 } from '@/app/components/base/icons/src/vender/solid/FinanceAndECommerce' +import { Target04 } from '@/app/components/base/icons/src/vender/solid/general' import { TONE_LIST } from '@/config' const toneI18nKeyMap = { diff --git a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/trigger.tsx b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/trigger.tsx index 06823d1a74..d9cb29abbf 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/trigger.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/trigger.tsx @@ -5,8 +5,8 @@ import type { ModelProvider, } from '../declarations' import { cn } from '@langgenius/dify-ui/cn' +import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { useTranslation } from 'react-i18next' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/app/components/base/ui/tooltip' import { useProviderContext } from '@/context/provider-context' import ModelIcon from '../model-icon' import ModelName from '../model-name' diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/popover.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/popover.spec.tsx index e92bab1db5..d7501672f4 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/popover.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/popover.spec.tsx @@ -16,7 +16,7 @@ vi.mock('../../hooks', () => ({ }), })) -vi.mock('@/app/components/base/ui/popover', () => ({ +vi.mock('@langgenius/dify-ui/popover', () => ({ Popover: ({ children, onOpenChange }: PopoverProps) => { latestOnOpenChange = onOpenChange return
{children}
diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/popup-item.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/popup-item.spec.tsx index 6728791120..341a9c6abc 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/popup-item.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/popup-item.spec.tsx @@ -44,7 +44,7 @@ vi.mock('@/app/components/base/tooltip', () => ({ default: ({ children }: { children: React.ReactNode }) =>
{children}
, })) -vi.mock('@/app/components/base/ui/popover', () => ({ +vi.mock('@langgenius/dify-ui/popover', () => ({ Popover: ({ children }: { children: React.ReactNode }) =>
{children}
, PopoverTrigger: ({ render }: { render: React.ReactNode }) => <>{render}, PopoverContent: ({ children }: { children: React.ReactNode }) =>
{children}
, diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/index.tsx b/web/app/components/header/account-setting/model-provider-page/model-selector/index.tsx index 44392a70a5..9241c592f5 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-selector/index.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-selector/index.tsx @@ -5,12 +5,12 @@ import type { ModelFeatureEnum, ModelItem, } from '../declarations' -import { useState } from 'react' import { Popover, PopoverContent, PopoverTrigger, -} from '@/app/components/base/ui/popover' +} from '@langgenius/dify-ui/popover' +import { useState } from 'react' import { useCurrentProviderAndModel } from '../hooks' import ModelSelectorTrigger from './model-selector-trigger' import Popup from './popup' diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/model-selector-trigger.tsx b/web/app/components/header/account-setting/model-provider-page/model-selector/model-selector-trigger.tsx index d43324ca12..6b9bcae8dc 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-selector/model-selector-trigger.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-selector/model-selector-trigger.tsx @@ -5,8 +5,8 @@ import type { ModelItem, } from '../declarations' import { cn } from '@langgenius/dify-ui/cn' +import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { useTranslation } from 'react-i18next' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/app/components/base/ui/tooltip' import { useProviderContext } from '@/context/provider-context' import { DERIVED_MODEL_STATUS_BADGE_I18N, diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/popup-item.tsx b/web/app/components/header/account-setting/model-provider-page/model-selector/popup-item.tsx index e38cc07ca2..ed16cd7904 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-selector/popup-item.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-selector/popup-item.tsx @@ -5,15 +5,15 @@ import type { ModelItem, } from '../declarations' import { cn } from '@langgenius/dify-ui/cn' -import { useCallback, useState } from 'react' -import { useTranslation } from 'react-i18next' -import { CreditsCoin } from '@/app/components/base/icons/src/vender/line/financeAndECommerce' import { Popover, PopoverContent, PopoverTrigger, -} from '@/app/components/base/ui/popover' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/app/components/base/ui/tooltip' +} from '@langgenius/dify-ui/popover' +import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' +import { useCallback, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { CreditsCoin } from '@/app/components/base/icons/src/vender/line/financeAndECommerce' import { useModalContext } from '@/context/modal-context' import { useProviderContext } from '@/context/provider-context' import { diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/popup.tsx b/web/app/components/header/account-setting/model-provider-page/model-selector/popup.tsx index b40bc53a65..017194aaf5 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-selector/popup.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-selector/popup.tsx @@ -5,11 +5,11 @@ import type { ModelItem, } from '../declarations' import type { ModelProviderQuotaGetPaid } from '@/types/model-provider' +import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { useTheme } from 'next-themes' import { useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' -import { Button } from '@/app/components/base/ui/button' import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' import checkTaskStatus from '@/app/components/plugins/install-plugin/base/check-task-status' import useRefreshPluginList from '@/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list' diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/credential-panel.spec.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/credential-panel.spec.tsx index 4f51de9b41..f07f9652f8 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/credential-panel.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/credential-panel.spec.tsx @@ -32,7 +32,7 @@ vi.mock('@/context/global-public-context', () => ({ useSystemFeaturesQuery: () => ({ data: { trial_models: ['langgenius/openai/openai'] } }), })) -vi.mock('@/app/components/base/ui/toast', () => ({ +vi.mock('@langgenius/dify-ui/toast', () => ({ default: { notify: mockToastNotify }, toast: { success: (message: string) => mockToastNotify({ type: 'success', message }), diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/model-load-balancing-modal.spec.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/model-load-balancing-modal.spec.tsx index eedc04115c..c4794c9775 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/model-load-balancing-modal.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/model-load-balancing-modal.spec.tsx @@ -52,8 +52,8 @@ let mockCredentialData: CredentialData | undefined = { current_credential_name: 'Default', } -vi.mock('@/app/components/base/ui/toast', async (importOriginal) => { - const actual = await importOriginal() +vi.mock('@langgenius/dify-ui/toast', async (importOriginal) => { + const actual = await importOriginal() return { ...actual, default: { diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/use-change-provider-priority.spec.ts b/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/use-change-provider-priority.spec.ts index 57c5121014..7f6e7d393a 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/use-change-provider-priority.spec.ts +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/use-change-provider-priority.spec.ts @@ -16,7 +16,7 @@ const mockMutationOptions = vi.fn((options: Record) => ({ ...options, })) -vi.mock('@/app/components/base/ui/toast', () => ({ +vi.mock('@langgenius/dify-ui/toast', () => ({ default: { notify: (...args: unknown[]) => mockNotify(...args), }, diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/__tests__/dialog.spec.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/__tests__/dialog.spec.tsx index b4ec0e09d2..85ae2d7e52 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/__tests__/dialog.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/__tests__/dialog.spec.tsx @@ -34,7 +34,7 @@ vi.mock('../use-activate-credential', () => ({ }), })) -vi.mock('@/app/components/base/ui/alert-dialog', () => ({ +vi.mock('@langgenius/dify-ui/alert-dialog', () => ({ AlertDialog: ({ children, onOpenChange }: AlertDialogProps) => { latestOnOpenChange = onOpenChange return
{children}
diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/__tests__/use-activate-credential.spec.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/__tests__/use-activate-credential.spec.tsx index 12acf479c0..aec9c9f691 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/__tests__/use-activate-credential.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/__tests__/use-activate-credential.spec.tsx @@ -1,6 +1,6 @@ import type { Credential, ModelProvider } from '../../../declarations' +import { toast } from '@langgenius/dify-ui/toast' import { act, renderHook } from '@testing-library/react' -import { toast } from '@/app/components/base/ui/toast' import { useActivateCredential } from '../use-activate-credential' const mockMutate = vi.fn() diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/api-key-section.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/api-key-section.tsx index e6f5e51d61..41645677b6 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/api-key-section.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/api-key-section.tsx @@ -1,7 +1,7 @@ import type { Credential, CustomModel, ModelProvider } from '../../declarations' +import { Button } from '@langgenius/dify-ui/button' import { memo } from 'react' import { useTranslation } from 'react-i18next' -import { Button } from '@/app/components/base/ui/button' import CredentialItem from '../../model-auth/authorized/credential-item' type ApiKeySectionProps = { diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/dropdown-content.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/dropdown-content.tsx index 415539383b..0e0f3a67b1 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/dropdown-content.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/dropdown-content.tsx @@ -1,7 +1,5 @@ import type { Credential, ModelProvider, PreferredProviderTypeEnum } from '../../declarations' import type { CredentialPanelState } from '../use-credential-panel-state' -import { memo, useCallback } from 'react' -import { useTranslation } from 'react-i18next' import { AlertDialog, AlertDialogActions, @@ -10,7 +8,9 @@ import { AlertDialogContent, AlertDialogDescription, AlertDialogTitle, -} from '@/app/components/base/ui/alert-dialog' +} from '@langgenius/dify-ui/alert-dialog' +import { memo, useCallback } from 'react' +import { useTranslation } from 'react-i18next' import { ConfigurationMethodEnum } from '../../declarations' import { useAuth } from '../../model-auth/hooks' import ApiKeySection from './api-key-section' diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/index.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/index.tsx index fc710ef73f..67f73f6941 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/index.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/index.tsx @@ -1,13 +1,13 @@ import type { ModelProvider, PreferredProviderTypeEnum } from '../../declarations' import type { CardVariant, CredentialPanelState } from '../use-credential-panel-state' -import { memo, useCallback, useState } from 'react' -import { useTranslation } from 'react-i18next' -import { Button } from '@/app/components/base/ui/button' +import { Button } from '@langgenius/dify-ui/button' import { Popover, PopoverContent, PopoverTrigger, -} from '@/app/components/base/ui/popover' +} from '@langgenius/dify-ui/popover' +import { memo, useCallback, useState } from 'react' +import { useTranslation } from 'react-i18next' import DropdownContent from './dropdown-content' type ModelAuthDropdownProps = { diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/usage-priority-section.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/usage-priority-section.tsx index 638f9d605b..4ec7ce3cef 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/usage-priority-section.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/usage-priority-section.tsx @@ -1,7 +1,7 @@ import type { UsagePriority } from '../use-credential-panel-state' import { cn } from '@langgenius/dify-ui/cn' +import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { useTranslation } from 'react-i18next' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/app/components/base/ui/tooltip' import { PreferredProviderTypeEnum } from '../../declarations' type UsagePrioritySectionProps = { diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/use-activate-credential.ts b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/use-activate-credential.ts index 6f99c32296..db9f735e3a 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/use-activate-credential.ts +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/use-activate-credential.ts @@ -1,7 +1,7 @@ import type { Credential, ModelProvider } from '../../declarations' +import { toast } from '@langgenius/dify-ui/toast' import { useCallback, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { toast } from '@/app/components/base/ui/toast' import { useActiveProviderCredential } from '@/service/use-models' import { useUpdateModelList, useUpdateModelProviders } from '../../hooks' diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-list-item.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-list-item.tsx index d8f0b5928c..305ef71c50 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-list-item.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-list-item.tsx @@ -1,12 +1,12 @@ import type { ModelItem, ModelProvider } from '../declarations' import { cn } from '@langgenius/dify-ui/cn' +import { Switch } from '@langgenius/dify-ui/switch' import { useQueryClient } from '@tanstack/react-query' import { useDebounceFn } from 'ahooks' import { memo, useCallback } from 'react' import { useTranslation } from 'react-i18next' import Badge from '@/app/components/base/badge' import { Balance } from '@/app/components/base/icons/src/vender/line/financeAndECommerce' -import Switch from '@/app/components/base/switch' import Tooltip from '@/app/components/base/tooltip' import { Plan } from '@/app/components/billing/type' import { useAppContext } from '@/context/app-context' diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-load-balancing-configs.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-load-balancing-configs.tsx index ea4edace30..9b7b858c13 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-load-balancing-configs.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-load-balancing-configs.tsx @@ -9,11 +9,11 @@ import type { ModelProvider, } from '../declarations' import { cn } from '@langgenius/dify-ui/cn' +import { Switch } from '@langgenius/dify-ui/switch' import { useCallback, useMemo } from 'react' import { useTranslation } from 'react-i18next' import Badge from '@/app/components/base/badge/index' import GridMask from '@/app/components/base/grid-mask' -import Switch from '@/app/components/base/switch' import Tooltip from '@/app/components/base/tooltip' import UpgradeBtn from '@/app/components/billing/upgrade-btn' import s from '@/app/components/custom/style.module.css' diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-load-balancing-modal.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-load-balancing-modal.tsx index 652630be67..34b9d1578f 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-load-balancing-modal.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-load-balancing-modal.tsx @@ -1,9 +1,4 @@ import type { Credential, CustomConfigurationModelFixedFields, ModelItem, ModelLoadBalancingConfig, ModelLoadBalancingConfigEntry, ModelProvider } from '../declarations' -import { cn } from '@langgenius/dify-ui/cn' -import { memo, useCallback, useEffect, useMemo, useState } from 'react' -import { useTranslation } from 'react-i18next' -import Loading from '@/app/components/base/loading' -import Modal from '@/app/components/base/modal' import { AlertDialog, AlertDialogActions, @@ -11,9 +6,14 @@ import { AlertDialogConfirmButton, AlertDialogContent, AlertDialogTitle, -} from '@/app/components/base/ui/alert-dialog' -import { Button } from '@/app/components/base/ui/button' -import { toast } from '@/app/components/base/ui/toast' +} from '@langgenius/dify-ui/alert-dialog' +import { Button } from '@langgenius/dify-ui/button' +import { cn } from '@langgenius/dify-ui/cn' +import { toast } from '@langgenius/dify-ui/toast' +import { memo, useCallback, useEffect, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import Loading from '@/app/components/base/loading' +import Modal from '@/app/components/base/modal' import { SwitchCredentialInLoadBalancing } from '@/app/components/header/account-setting/model-provider-page/model-auth' import { useGetModelCredential, useUpdateModelLoadBalancingConfig } from '@/service/use-models' import { ConfigurationMethodEnum, FormTypeEnum } from '../declarations' diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/priority-selector.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/priority-selector.tsx index e71a219dcd..a74c400035 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/priority-selector.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/priority-selector.tsx @@ -1,5 +1,6 @@ import type { FC } from 'react' import { Popover, PopoverButton, PopoverPanel, Transition } from '@headlessui/react' +import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { RiCheckLine, @@ -7,7 +8,6 @@ import { } from '@remixicon/react' import { Fragment } from 'react' import { useTranslation } from 'react-i18next' -import { Button } from '@/app/components/base/ui/button' import { PreferredProviderTypeEnum } from '../declarations' type SelectorProps = { diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/provider-card-actions.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/provider-card-actions.tsx index 2269354825..861c4afa7a 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/provider-card-actions.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/provider-card-actions.tsx @@ -1,11 +1,11 @@ import type { FC } from 'react' import type { PluginDetail } from '@/app/components/plugins/types' +import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' +import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { useMemo } from 'react' import { useTranslation } from 'react-i18next' import Badge from '@/app/components/base/badge' -import { Button } from '@/app/components/base/ui/button' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/app/components/base/ui/tooltip' import { HeaderModals } from '@/app/components/plugins/plugin-detail-panel/detail-header/components' import { useDetailHeaderState, usePluginOperations } from '@/app/components/plugins/plugin-detail-panel/detail-header/hooks' import OperationDropdown from '@/app/components/plugins/plugin-detail-panel/operation-dropdown' diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/quota-panel.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/quota-panel.tsx index 03d7c28a88..5d31db11f9 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/quota-panel.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/quota-panel.tsx @@ -3,12 +3,12 @@ import type { ModelProvider } from '../declarations' import type { Plugin } from '@/app/components/plugins/types' import type { ModelProviderQuotaGetPaid } from '@/types/model-provider' import { cn } from '@langgenius/dify-ui/cn' +import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { useBoolean } from 'ahooks' import * as React from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import Loading from '@/app/components/base/loading' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/app/components/base/ui/tooltip' import InstallFromMarketplace from '@/app/components/plugins/install-plugin/install-from-marketplace' import { useSystemFeaturesQuery } from '@/context/global-public-context' import useTimestamp from '@/hooks/use-timestamp' diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/use-change-provider-priority.ts b/web/app/components/header/account-setting/model-provider-page/provider-added-card/use-change-provider-priority.ts index 2a9e7e62ac..c339af4b2e 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/use-change-provider-priority.ts +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/use-change-provider-priority.ts @@ -1,7 +1,7 @@ import type { ModelProvider, PreferredProviderTypeEnum } from '../declarations' +import { toast } from '@langgenius/dify-ui/toast' import { useMutation, useQueryClient } from '@tanstack/react-query' import { useTranslation } from 'react-i18next' -import { toast } from '@/app/components/base/ui/toast' import { consoleQuery } from '@/service/client' import { ConfigurationMethodEnum } from '../declarations' import { useUpdateModelList, useUpdateModelProviders } from '../hooks' diff --git a/web/app/components/header/account-setting/model-provider-page/system-model-selector/__tests__/index.spec.tsx b/web/app/components/header/account-setting/model-provider-page/system-model-selector/__tests__/index.spec.tsx index c1b27a2c04..8129ed721c 100644 --- a/web/app/components/header/account-setting/model-provider-page/system-model-selector/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/system-model-selector/__tests__/index.spec.tsx @@ -43,8 +43,8 @@ vi.mock('@/context/provider-context', () => ({ }), })) -vi.mock('@/app/components/base/ui/toast', async (importOriginal) => { - const actual = await importOriginal() +vi.mock('@langgenius/dify-ui/toast', async (importOriginal) => { + const actual = await importOriginal() return { ...actual, toast: { diff --git a/web/app/components/header/account-setting/model-provider-page/system-model-selector/index.tsx b/web/app/components/header/account-setting/model-provider-page/system-model-selector/index.tsx index 8b86923391..41bb3c3ab8 100644 --- a/web/app/components/header/account-setting/model-provider-page/system-model-selector/index.tsx +++ b/web/app/components/header/account-setting/model-provider-page/system-model-selector/index.tsx @@ -3,21 +3,21 @@ import type { DefaultModel, DefaultModelResponse, } from '../declarations' -import { useState } from 'react' -import { useTranslation } from 'react-i18next' -import { Button } from '@/app/components/base/ui/button' +import { Button } from '@langgenius/dify-ui/button' import { Dialog, DialogCloseButton, DialogContent, DialogTitle, -} from '@/app/components/base/ui/dialog' -import { toast } from '@/app/components/base/ui/toast' +} from '@langgenius/dify-ui/dialog' +import { toast } from '@langgenius/dify-ui/toast' import { Tooltip, TooltipContent, TooltipTrigger, -} from '@/app/components/base/ui/tooltip' +} from '@langgenius/dify-ui/tooltip' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' import { useAppContext } from '@/context/app-context' import { useProviderContext } from '@/context/provider-context' import { updateDefaultModel } from '@/service/common' diff --git a/web/app/components/header/account-setting/plugin-page/SerpapiPlugin.tsx b/web/app/components/header/account-setting/plugin-page/SerpapiPlugin.tsx index 7308691b54..31c4ce2c3a 100644 --- a/web/app/components/header/account-setting/plugin-page/SerpapiPlugin.tsx +++ b/web/app/components/header/account-setting/plugin-page/SerpapiPlugin.tsx @@ -1,7 +1,7 @@ import type { Form, ValidateValue } from '../key-validator/declarations' import type { PluginProvider } from '@/models/common' +import { toast } from '@langgenius/dify-ui/toast' import { useTranslation } from 'react-i18next' -import { toast } from '@/app/components/base/ui/toast' import { useAppContext } from '@/context/app-context' import SerpapiLogo from '../../assets/serpapi.png' import KeyValidator from '../key-validator' diff --git a/web/app/components/header/account-setting/plugin-page/__tests__/SerpapiPlugin.spec.tsx b/web/app/components/header/account-setting/plugin-page/__tests__/SerpapiPlugin.spec.tsx index 85d205713b..2055c0725d 100644 --- a/web/app/components/header/account-setting/plugin-page/__tests__/SerpapiPlugin.spec.tsx +++ b/web/app/components/header/account-setting/plugin-page/__tests__/SerpapiPlugin.spec.tsx @@ -32,7 +32,7 @@ const { mockToast } = vi.hoisted(() => { return { mockToast } }) -vi.mock('@/app/components/base/ui/toast', () => ({ +vi.mock('@langgenius/dify-ui/toast', () => ({ toast: mockToast, })) diff --git a/web/app/components/header/account-setting/plugin-page/__tests__/index.spec.tsx b/web/app/components/header/account-setting/plugin-page/__tests__/index.spec.tsx index ff58fdd182..ca0c41a83b 100644 --- a/web/app/components/header/account-setting/plugin-page/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/plugin-page/__tests__/index.spec.tsx @@ -14,8 +14,8 @@ vi.mock('@/context/app-context', () => ({ useAppContext: vi.fn(), })) -vi.mock('@/app/components/base/ui/toast', async (importOriginal) => { - const actual = await importOriginal() +vi.mock('@langgenius/dify-ui/toast', async (importOriginal) => { + const actual = await importOriginal() return { ...actual, diff --git a/web/app/components/plugins/install-plugin/__tests__/hooks.spec.ts b/web/app/components/plugins/install-plugin/__tests__/hooks.spec.ts index b4171de7f0..385b9e01e1 100644 --- a/web/app/components/plugins/install-plugin/__tests__/hooks.spec.ts +++ b/web/app/components/plugins/install-plugin/__tests__/hooks.spec.ts @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { checkForUpdates, fetchReleases, handleUpload } from '../hooks' const mockNotify = vi.fn() -vi.mock('@/app/components/base/ui/toast', () => ({ +vi.mock('@langgenius/dify-ui/toast', () => ({ toast: Object.assign((...args: unknown[]) => mockNotify(...args), { success: (...args: unknown[]) => mockNotify(...args), error: (...args: unknown[]) => mockNotify(...args), diff --git a/web/app/components/plugins/install-plugin/base/installed.tsx b/web/app/components/plugins/install-plugin/base/installed.tsx index 088d587378..7b76767e77 100644 --- a/web/app/components/plugins/install-plugin/base/installed.tsx +++ b/web/app/components/plugins/install-plugin/base/installed.tsx @@ -1,10 +1,10 @@ 'use client' import type { FC } from 'react' import type { Plugin, PluginDeclaration, PluginManifestInMarket } from '../../types' +import { Button } from '@langgenius/dify-ui/button' import * as React from 'react' import { useTranslation } from 'react-i18next' import Badge, { BadgeState } from '@/app/components/base/badge/index' -import { Button } from '@/app/components/base/ui/button' import Card from '../../card' import { pluginManifestInMarketToPluginProps, pluginManifestToCardPluginProps } from '../utils' diff --git a/web/app/components/plugins/install-plugin/hooks.ts b/web/app/components/plugins/install-plugin/hooks.ts index 4759953e30..e7086a7fba 100644 --- a/web/app/components/plugins/install-plugin/hooks.ts +++ b/web/app/components/plugins/install-plugin/hooks.ts @@ -1,5 +1,5 @@ import type { GitHubRepoReleaseResponse } from '../types' -import { toast } from '@/app/components/base/ui/toast' +import { toast } from '@langgenius/dify-ui/toast' import { uploadGitHub } from '@/service/plugins' import { compareVersion, getLatestVersion } from '@/utils/semver' diff --git a/web/app/components/plugins/install-plugin/install-bundle/steps/install.tsx b/web/app/components/plugins/install-plugin/install-bundle/steps/install.tsx index df94331d58..a94cd8588d 100644 --- a/web/app/components/plugins/install-plugin/install-bundle/steps/install.tsx +++ b/web/app/components/plugins/install-plugin/install-bundle/steps/install.tsx @@ -2,12 +2,12 @@ import type { FC } from 'react' import type { Dependency, InstallStatus, InstallStatusResponse, Plugin, VersionInfo } from '../../../types' import type { ExposeRefs } from './install-multi' +import { Button } from '@langgenius/dify-ui/button' import { RiLoader2Line } from '@remixicon/react' import * as React from 'react' import { useCallback, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import Checkbox from '@/app/components/base/checkbox' -import { Button } from '@/app/components/base/ui/button' import { useCanInstallPluginFromMarketplace } from '@/app/components/plugins/plugin-page/use-reference-setting' import { useMittContextSelector } from '@/context/mitt-context' import { useInstallOrUpdate, usePluginTaskList } from '@/service/use-plugins' diff --git a/web/app/components/plugins/install-plugin/install-bundle/steps/installed.tsx b/web/app/components/plugins/install-plugin/install-bundle/steps/installed.tsx index 1c64e73d62..3fbf0c13ec 100644 --- a/web/app/components/plugins/install-plugin/install-bundle/steps/installed.tsx +++ b/web/app/components/plugins/install-plugin/install-bundle/steps/installed.tsx @@ -1,10 +1,10 @@ 'use client' import type { FC } from 'react' import type { InstallStatus, Plugin } from '../../../types' +import { Button } from '@langgenius/dify-ui/button' import * as React from 'react' import { useTranslation } from 'react-i18next' import Badge, { BadgeState } from '@/app/components/base/badge/index' -import { Button } from '@/app/components/base/ui/button' import Card from '@/app/components/plugins/card' import { MARKETPLACE_API_PREFIX } from '@/config' import useGetIcon from '../../base/use-get-icon' diff --git a/web/app/components/plugins/install-plugin/install-from-github/__tests__/index.spec.tsx b/web/app/components/plugins/install-plugin/install-from-github/__tests__/index.spec.tsx index 2f35f484f0..e923de5e38 100644 --- a/web/app/components/plugins/install-plugin/install-from-github/__tests__/index.spec.tsx +++ b/web/app/components/plugins/install-plugin/install-from-github/__tests__/index.spec.tsx @@ -57,7 +57,7 @@ const createUpdatePayload = (overrides: Partial = {}): // Mock external dependencies const mockNotify = vi.fn() -vi.mock('@/app/components/base/ui/toast', () => ({ +vi.mock('@langgenius/dify-ui/toast', () => ({ toast: Object.assign((props: { type: string, message: string }) => mockNotify(props), { success: (message: string) => mockNotify({ type: 'success', message }), error: (message: string) => mockNotify({ type: 'error', message }), diff --git a/web/app/components/plugins/install-plugin/install-from-github/index.tsx b/web/app/components/plugins/install-plugin/install-from-github/index.tsx index f1e4d1b9bf..4ac0a3aa7f 100644 --- a/web/app/components/plugins/install-plugin/install-from-github/index.tsx +++ b/web/app/components/plugins/install-plugin/install-from-github/index.tsx @@ -4,11 +4,11 @@ import type { PluginDeclaration, UpdateFromGitHubPayload } from '../../types' import type { Item } from '@/app/components/base/select' import type { InstallState } from '@/app/components/plugins/types' import { cn } from '@langgenius/dify-ui/cn' +import { toast } from '@langgenius/dify-ui/toast' import * as React from 'react' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import Modal from '@/app/components/base/modal' -import { toast } from '@/app/components/base/ui/toast' import useGetIcon from '@/app/components/plugins/install-plugin/base/use-get-icon' import { InstallStepFromGitHub } from '../../types' import Installed from '../base/installed' diff --git a/web/app/components/plugins/install-plugin/install-from-github/steps/loaded.tsx b/web/app/components/plugins/install-plugin/install-from-github/steps/loaded.tsx index c044445f19..ff7630e3a2 100644 --- a/web/app/components/plugins/install-plugin/install-from-github/steps/loaded.tsx +++ b/web/app/components/plugins/install-plugin/install-from-github/steps/loaded.tsx @@ -1,11 +1,11 @@ 'use client' import type { Plugin, PluginDeclaration, UpdateFromGitHubPayload } from '../../../types' +import { Button } from '@langgenius/dify-ui/button' import { RiLoader2Line } from '@remixicon/react' import * as React from 'react' import { useEffect } from 'react' import { useTranslation } from 'react-i18next' -import { Button } from '@/app/components/base/ui/button' import useCheckInstalled from '@/app/components/plugins/install-plugin/hooks/use-check-installed' import { updateFromGitHub } from '@/service/plugins' import { useInstallPackageFromGitHub, usePluginTaskList } from '@/service/use-plugins' diff --git a/web/app/components/plugins/install-plugin/install-from-github/steps/selectPackage.tsx b/web/app/components/plugins/install-plugin/install-from-github/steps/selectPackage.tsx index a8236b4b93..18c33def82 100644 --- a/web/app/components/plugins/install-plugin/install-from-github/steps/selectPackage.tsx +++ b/web/app/components/plugins/install-plugin/install-from-github/steps/selectPackage.tsx @@ -2,10 +2,10 @@ import type { PluginDeclaration, UpdateFromGitHubPayload } from '../../../types' import type { Item } from '@/app/components/base/select' +import { Button } from '@langgenius/dify-ui/button' import * as React from 'react' import { useTranslation } from 'react-i18next' import { PortalSelect } from '@/app/components/base/select' -import { Button } from '@/app/components/base/ui/button' import { handleUpload } from '../../hooks' const i18nPrefix = 'installFromGitHub' diff --git a/web/app/components/plugins/install-plugin/install-from-github/steps/setURL.tsx b/web/app/components/plugins/install-plugin/install-from-github/steps/setURL.tsx index 2a7012d40c..f1b149a8bf 100644 --- a/web/app/components/plugins/install-plugin/install-from-github/steps/setURL.tsx +++ b/web/app/components/plugins/install-plugin/install-from-github/steps/setURL.tsx @@ -1,8 +1,8 @@ 'use client' +import { Button } from '@langgenius/dify-ui/button' import * as React from 'react' import { useTranslation } from 'react-i18next' -import { Button } from '@/app/components/base/ui/button' type SetURLProps = { repoUrl: string diff --git a/web/app/components/plugins/install-plugin/install-from-local-package/steps/install.tsx b/web/app/components/plugins/install-plugin/install-from-local-package/steps/install.tsx index 9ef52d7a5d..7c0a90f179 100644 --- a/web/app/components/plugins/install-plugin/install-from-local-package/steps/install.tsx +++ b/web/app/components/plugins/install-plugin/install-from-local-package/steps/install.tsx @@ -1,11 +1,11 @@ 'use client' import type { FC } from 'react' import type { PluginDeclaration } from '../../../types' +import { Button } from '@langgenius/dify-ui/button' import { RiLoader2Line } from '@remixicon/react' import * as React from 'react' import { useEffect, useMemo } from 'react' import { Trans, useTranslation } from 'react-i18next' -import { Button } from '@/app/components/base/ui/button' import useCheckInstalled from '@/app/components/plugins/install-plugin/hooks/use-check-installed' import { useAppContext } from '@/context/app-context' import { uninstallPlugin } from '@/service/plugins' diff --git a/web/app/components/plugins/install-plugin/install-from-local-package/steps/uploading.tsx b/web/app/components/plugins/install-plugin/install-from-local-package/steps/uploading.tsx index f9f9969817..e7c3b4cd0f 100644 --- a/web/app/components/plugins/install-plugin/install-from-local-package/steps/uploading.tsx +++ b/web/app/components/plugins/install-plugin/install-from-local-package/steps/uploading.tsx @@ -1,10 +1,10 @@ 'use client' import type { FC } from 'react' import type { Dependency, PluginDeclaration } from '../../../types' +import { Button } from '@langgenius/dify-ui/button' import { RiLoader2Line } from '@remixicon/react' import * as React from 'react' import { useTranslation } from 'react-i18next' -import { Button } from '@/app/components/base/ui/button' import { uploadFile } from '@/service/plugins' import Card from '../../../card' diff --git a/web/app/components/plugins/install-plugin/install-from-marketplace/steps/install.tsx b/web/app/components/plugins/install-plugin/install-from-marketplace/steps/install.tsx index ac8b458406..91397f3189 100644 --- a/web/app/components/plugins/install-plugin/install-from-marketplace/steps/install.tsx +++ b/web/app/components/plugins/install-plugin/install-from-marketplace/steps/install.tsx @@ -1,11 +1,11 @@ 'use client' import type { FC } from 'react' import type { Plugin, PluginManifestInMarket } from '../../../types' +import { Button } from '@langgenius/dify-ui/button' import { RiLoader2Line } from '@remixicon/react' import * as React from 'react' import { useEffect, useMemo } from 'react' import { useTranslation } from 'react-i18next' -import { Button } from '@/app/components/base/ui/button' import useCheckInstalled from '@/app/components/plugins/install-plugin/hooks/use-check-installed' import { useAppContext } from '@/context/app-context' import { useInstallPackageFromMarketPlace, usePluginDeclarationFromMarketPlace, usePluginTaskList, useUpdatePackageFromMarketPlace } from '@/service/use-plugins' diff --git a/web/app/components/plugins/marketplace/list/card-wrapper.tsx b/web/app/components/plugins/marketplace/list/card-wrapper.tsx index 6cab636392..9dc5bc3d78 100644 --- a/web/app/components/plugins/marketplace/list/card-wrapper.tsx +++ b/web/app/components/plugins/marketplace/list/card-wrapper.tsx @@ -1,12 +1,12 @@ 'use client' import type { Plugin } from '@/app/components/plugins/types' import { useLocale, useTranslation } from '#i18n' +import { Button } from '@langgenius/dify-ui/button' import { RiArrowRightUpLine } from '@remixicon/react' import { useBoolean } from 'ahooks' import { useTheme } from 'next-themes' import * as React from 'react' import { useMemo } from 'react' -import { Button } from '@/app/components/base/ui/button' import Card from '@/app/components/plugins/card' import CardMoreInfo from '@/app/components/plugins/card/card-more-info' import { useTags } from '@/app/components/plugins/hooks' diff --git a/web/app/components/plugins/marketplace/sort-dropdown/__tests__/index.spec.tsx b/web/app/components/plugins/marketplace/sort-dropdown/__tests__/index.spec.tsx index 990bb321de..5bf5b6bb99 100644 --- a/web/app/components/plugins/marketplace/sort-dropdown/__tests__/index.spec.tsx +++ b/web/app/components/plugins/marketplace/sort-dropdown/__tests__/index.spec.tsx @@ -31,7 +31,7 @@ vi.mock('../../atoms', () => ({ useMarketplaceSort: () => [mockSort, mockHandleSortChange], })) -vi.mock('@/app/components/base/ui/dropdown-menu', async () => { +vi.mock('@langgenius/dify-ui/dropdown-menu', async () => { const React = await import('react') const DropdownMenuContext = React.createContext<{ open: boolean, setOpen: (open: boolean) => void } | null>(null) diff --git a/web/app/components/plugins/marketplace/sort-dropdown/index.tsx b/web/app/components/plugins/marketplace/sort-dropdown/index.tsx index a47143de02..b8f5467fa1 100644 --- a/web/app/components/plugins/marketplace/sort-dropdown/index.tsx +++ b/web/app/components/plugins/marketplace/sort-dropdown/index.tsx @@ -1,12 +1,12 @@ 'use client' import { useTranslation } from '#i18n' -import { useState } from 'react' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, -} from '@/app/components/base/ui/dropdown-menu' +} from '@langgenius/dify-ui/dropdown-menu' +import { useState } from 'react' import { useMarketplaceSort } from '../atoms' const SortDropdown = () => { diff --git a/web/app/components/plugins/plugin-auth/authorize/__tests__/api-key-modal.spec.tsx b/web/app/components/plugins/plugin-auth/authorize/__tests__/api-key-modal.spec.tsx index a4ddec8f51..2bfa94d2ed 100644 --- a/web/app/components/plugins/plugin-auth/authorize/__tests__/api-key-modal.spec.tsx +++ b/web/app/components/plugins/plugin-auth/authorize/__tests__/api-key-modal.spec.tsx @@ -15,7 +15,7 @@ const mockToast = { promise: vi.fn(), } -vi.mock('@/app/components/base/ui/toast', () => ({ +vi.mock('@langgenius/dify-ui/toast', () => ({ toast: mockToast, })) const mockAddPluginCredential = vi.fn().mockResolvedValue({}) diff --git a/web/app/components/plugins/plugin-auth/authorize/__tests__/authorize-components.spec.tsx b/web/app/components/plugins/plugin-auth/authorize/__tests__/authorize-components.spec.tsx index c495d5501a..cba5c60654 100644 --- a/web/app/components/plugins/plugin-auth/authorize/__tests__/authorize-components.spec.tsx +++ b/web/app/components/plugins/plugin-auth/authorize/__tests__/authorize-components.spec.tsx @@ -104,7 +104,7 @@ const mockToast = { promise: vi.fn(), } -vi.mock('@/app/components/base/ui/toast', () => ({ +vi.mock('@langgenius/dify-ui/toast', () => ({ toast: mockToast, })) // Factory function for creating test PluginPayload diff --git a/web/app/components/plugins/plugin-auth/authorize/__tests__/oauth-client-settings.spec.tsx b/web/app/components/plugins/plugin-auth/authorize/__tests__/oauth-client-settings.spec.tsx index 5f9b6f5695..2c86820202 100644 --- a/web/app/components/plugins/plugin-auth/authorize/__tests__/oauth-client-settings.spec.tsx +++ b/web/app/components/plugins/plugin-auth/authorize/__tests__/oauth-client-settings.spec.tsx @@ -14,7 +14,7 @@ const mockToast = { promise: vi.fn(), } -vi.mock('@/app/components/base/ui/toast', () => ({ +vi.mock('@langgenius/dify-ui/toast', () => ({ toast: mockToast, })) const mockSetPluginOAuthCustomClient = vi.fn().mockResolvedValue({}) diff --git a/web/app/components/plugins/plugin-auth/authorize/add-api-key-button.tsx b/web/app/components/plugins/plugin-auth/authorize/add-api-key-button.tsx index 2db1751bdc..648a87dabc 100644 --- a/web/app/components/plugins/plugin-auth/authorize/add-api-key-button.tsx +++ b/web/app/components/plugins/plugin-auth/authorize/add-api-key-button.tsx @@ -1,11 +1,11 @@ +import type { ButtonProps } from '@langgenius/dify-ui/button' import type { PluginPayload } from '../types' import type { FormSchema } from '@/app/components/base/form/types' -import type { ButtonProps } from '@/app/components/base/ui/button' +import { Button } from '@langgenius/dify-ui/button' import { memo, useState, } from 'react' -import { Button } from '@/app/components/base/ui/button' import ApiKeyModal from './api-key-modal' export type AddApiKeyButtonProps = { diff --git a/web/app/components/plugins/plugin-auth/authorize/add-oauth-button.tsx b/web/app/components/plugins/plugin-auth/authorize/add-oauth-button.tsx index 31e87564e7..44b48db7a2 100644 --- a/web/app/components/plugins/plugin-auth/authorize/add-oauth-button.tsx +++ b/web/app/components/plugins/plugin-auth/authorize/add-oauth-button.tsx @@ -1,6 +1,7 @@ +import type { ButtonProps } from '@langgenius/dify-ui/button' import type { PluginPayload } from '../types' import type { FormSchema } from '@/app/components/base/form/types' -import type { ButtonProps } from '@/app/components/base/ui/button' +import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { RiClipboardLine, @@ -17,7 +18,6 @@ import { useTranslation } from 'react-i18next' import ActionButton from '@/app/components/base/action-button' import Badge from '@/app/components/base/badge' import { FormTypeEnum } from '@/app/components/base/form/types' -import { Button } from '@/app/components/base/ui/button' import { useRenderI18nObject } from '@/hooks/use-i18n' import { openOAuthPopup } from '@/hooks/use-oauth' import { diff --git a/web/app/components/plugins/plugin-auth/authorize/api-key-modal.tsx b/web/app/components/plugins/plugin-auth/authorize/api-key-modal.tsx index 5d8eafbe67..db513ecb6f 100644 --- a/web/app/components/plugins/plugin-auth/authorize/api-key-modal.tsx +++ b/web/app/components/plugins/plugin-auth/authorize/api-key-modal.tsx @@ -3,6 +3,7 @@ import type { FormRefObject, FormSchema, } from '@/app/components/base/form/types' +import { toast } from '@langgenius/dify-ui/toast' import { memo, useCallback, @@ -16,7 +17,6 @@ import AuthForm from '@/app/components/base/form/form-scenarios/auth' import { FormTypeEnum } from '@/app/components/base/form/types' import Loading from '@/app/components/base/loading' import Modal from '@/app/components/base/modal/modal' -import { toast } from '@/app/components/base/ui/toast' import { ReadmeEntrance } from '../../readme-panel/entrance' import { ReadmeShowType } from '../../readme-panel/store' import { diff --git a/web/app/components/plugins/plugin-auth/authorize/oauth-client-settings.tsx b/web/app/components/plugins/plugin-auth/authorize/oauth-client-settings.tsx index 625fcaa980..f52b76866a 100644 --- a/web/app/components/plugins/plugin-auth/authorize/oauth-client-settings.tsx +++ b/web/app/components/plugins/plugin-auth/authorize/oauth-client-settings.tsx @@ -3,6 +3,8 @@ import type { FormRefObject, FormSchema, } from '@/app/components/base/form/types' +import { Button } from '@langgenius/dify-ui/button' +import { toast } from '@langgenius/dify-ui/toast' import { useForm, useStore, @@ -16,8 +18,6 @@ import { import { useTranslation } from 'react-i18next' import AuthForm from '@/app/components/base/form/form-scenarios/auth' import Modal from '@/app/components/base/modal/modal' -import { Button } from '@/app/components/base/ui/button' -import { toast } from '@/app/components/base/ui/toast' import { ReadmeEntrance } from '../../readme-panel/entrance' import { ReadmeShowType } from '../../readme-panel/store' import { diff --git a/web/app/components/plugins/plugin-auth/authorized-in-data-source-node.tsx b/web/app/components/plugins/plugin-auth/authorized-in-data-source-node.tsx index 548b1fb175..13044d7bbc 100644 --- a/web/app/components/plugins/plugin-auth/authorized-in-data-source-node.tsx +++ b/web/app/components/plugins/plugin-auth/authorized-in-data-source-node.tsx @@ -1,10 +1,10 @@ +import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { RiEqualizer2Line } from '@remixicon/react' import { memo, } from 'react' import { useTranslation } from 'react-i18next' -import { Button } from '@/app/components/base/ui/button' import Indicator from '@/app/components/header/indicator' type AuthorizedInDataSourceNodeProps = { diff --git a/web/app/components/plugins/plugin-auth/authorized-in-node.tsx b/web/app/components/plugins/plugin-auth/authorized-in-node.tsx index 048bbf3e8f..e4f55e82e3 100644 --- a/web/app/components/plugins/plugin-auth/authorized-in-node.tsx +++ b/web/app/components/plugins/plugin-auth/authorized-in-node.tsx @@ -2,6 +2,7 @@ import type { Credential, PluginPayload, } from './types' +import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { RiArrowDownSLine } from '@remixicon/react' import { @@ -10,7 +11,6 @@ import { useState, } from 'react' import { useTranslation } from 'react-i18next' -import { Button } from '@/app/components/base/ui/button' import Indicator from '@/app/components/header/indicator' import { Authorized, diff --git a/web/app/components/plugins/plugin-auth/authorized/__tests__/index.spec.tsx b/web/app/components/plugins/plugin-auth/authorized/__tests__/index.spec.tsx index a127067526..01e195b21b 100644 --- a/web/app/components/plugins/plugin-auth/authorized/__tests__/index.spec.tsx +++ b/web/app/components/plugins/plugin-auth/authorized/__tests__/index.spec.tsx @@ -57,7 +57,7 @@ const toastMocks = vi.hoisted(() => ({ promise: vi.fn(), })) -vi.mock('@/app/components/base/ui/toast', () => ({ +vi.mock('@langgenius/dify-ui/toast', () => ({ toast: Object.assign(toastMocks.call, { success: vi.fn((message: string, options?: Record) => toastMocks.call({ type: 'success', message, ...options })), error: vi.fn((message: string, options?: Record) => toastMocks.call({ type: 'error', message, ...options })), diff --git a/web/app/components/plugins/plugin-auth/authorized/index.tsx b/web/app/components/plugins/plugin-auth/authorized/index.tsx index 4729909a1f..fed2873b98 100644 --- a/web/app/components/plugins/plugin-auth/authorized/index.tsx +++ b/web/app/components/plugins/plugin-auth/authorized/index.tsx @@ -2,7 +2,17 @@ import type { Credential, PluginPayload } from '../types' import type { PortalToFollowElemOptions, } from '@/app/components/base/portal-to-follow-elem' +import { + AlertDialog, + AlertDialogActions, + AlertDialogCancelButton, + AlertDialogConfirmButton, + AlertDialogContent, + AlertDialogTitle, +} from '@langgenius/dify-ui/alert-dialog' +import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' +import { toast } from '@langgenius/dify-ui/toast' import { RiArrowDownSLine, } from '@remixicon/react' @@ -18,16 +28,6 @@ import { PortalToFollowElemContent, PortalToFollowElemTrigger, } from '@/app/components/base/portal-to-follow-elem' -import { - AlertDialog, - AlertDialogActions, - AlertDialogCancelButton, - AlertDialogConfirmButton, - AlertDialogContent, - AlertDialogTitle, -} from '@/app/components/base/ui/alert-dialog' -import { Button } from '@/app/components/base/ui/button' -import { toast } from '@/app/components/base/ui/toast' import Indicator from '@/app/components/header/indicator' import Authorize from '../authorize' import ApiKeyModal from '../authorize/api-key-modal' diff --git a/web/app/components/plugins/plugin-auth/authorized/item.tsx b/web/app/components/plugins/plugin-auth/authorized/item.tsx index 82e3fc4563..9193238a55 100644 --- a/web/app/components/plugins/plugin-auth/authorized/item.tsx +++ b/web/app/components/plugins/plugin-auth/authorized/item.tsx @@ -1,4 +1,5 @@ import type { Credential } from '../types' +import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { RiCheckLine, @@ -16,7 +17,6 @@ import ActionButton from '@/app/components/base/action-button' import Badge from '@/app/components/base/badge' import Input from '@/app/components/base/input' import Tooltip from '@/app/components/base/tooltip' -import { Button } from '@/app/components/base/ui/button' import Indicator from '@/app/components/header/indicator' import { CredentialTypeEnum } from '../types' diff --git a/web/app/components/plugins/plugin-auth/hooks/__tests__/use-plugin-auth-action.spec.ts b/web/app/components/plugins/plugin-auth/hooks/__tests__/use-plugin-auth-action.spec.ts index ddf8f83019..06af4a88ee 100644 --- a/web/app/components/plugins/plugin-auth/hooks/__tests__/use-plugin-auth-action.spec.ts +++ b/web/app/components/plugins/plugin-auth/hooks/__tests__/use-plugin-auth-action.spec.ts @@ -16,7 +16,7 @@ const toastMocks = vi.hoisted(() => ({ promise: vi.fn(), })) -vi.mock('@/app/components/base/ui/toast', () => ({ +vi.mock('@langgenius/dify-ui/toast', () => ({ toast: Object.assign(toastMocks.call, { success: vi.fn((message: string, options?: Record) => toastMocks.call({ type: 'success', message, ...options })), error: vi.fn((message: string, options?: Record) => toastMocks.call({ type: 'error', message, ...options })), diff --git a/web/app/components/plugins/plugin-auth/hooks/use-plugin-auth-action.ts b/web/app/components/plugins/plugin-auth/hooks/use-plugin-auth-action.ts index b4714ff96c..7c63753ee8 100644 --- a/web/app/components/plugins/plugin-auth/hooks/use-plugin-auth-action.ts +++ b/web/app/components/plugins/plugin-auth/hooks/use-plugin-auth-action.ts @@ -1,11 +1,11 @@ import type { PluginPayload } from '../types' +import { toast } from '@langgenius/dify-ui/toast' import { useCallback, useRef, useState, } from 'react' import { useTranslation } from 'react-i18next' -import { toast } from '@/app/components/base/ui/toast' import { useDeletePluginCredentialHook, useSetPluginDefaultCredentialHook, diff --git a/web/app/components/plugins/plugin-auth/plugin-auth-in-agent.tsx b/web/app/components/plugins/plugin-auth/plugin-auth-in-agent.tsx index f54093f0f9..fd698f811c 100644 --- a/web/app/components/plugins/plugin-auth/plugin-auth-in-agent.tsx +++ b/web/app/components/plugins/plugin-auth/plugin-auth-in-agent.tsx @@ -2,6 +2,7 @@ import type { Credential, PluginPayload, } from './types' +import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { RiArrowDownSLine } from '@remixicon/react' import { @@ -10,7 +11,6 @@ import { useState, } from 'react' import { useTranslation } from 'react-i18next' -import { Button } from '@/app/components/base/ui/button' import Indicator from '@/app/components/header/indicator' import Authorize from './authorize' import Authorized from './authorized' diff --git a/web/app/components/plugins/plugin-auth/plugin-auth-in-datasource-node.tsx b/web/app/components/plugins/plugin-auth/plugin-auth-in-datasource-node.tsx index e3df399a1c..5cdc893ddd 100644 --- a/web/app/components/plugins/plugin-auth/plugin-auth-in-datasource-node.tsx +++ b/web/app/components/plugins/plugin-auth/plugin-auth-in-datasource-node.tsx @@ -1,8 +1,8 @@ import type { ReactNode } from 'react' +import { Button } from '@langgenius/dify-ui/button' import { RiAddLine } from '@remixicon/react' import { memo } from 'react' import { useTranslation } from 'react-i18next' -import { Button } from '@/app/components/base/ui/button' type PluginAuthInDataSourceNodeProps = { children?: ReactNode diff --git a/web/app/components/plugins/plugin-detail-panel/__tests__/detail-header.spec.tsx b/web/app/components/plugins/plugin-detail-panel/__tests__/detail-header.spec.tsx index 0eacbf3bd3..63cf25d039 100644 --- a/web/app/components/plugins/plugin-detail-panel/__tests__/detail-header.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/__tests__/detail-header.spec.tsx @@ -17,7 +17,7 @@ const { mockToast } = vi.hoisted(() => ({ }), })) -vi.mock('@/app/components/base/ui/toast', () => ({ +vi.mock('@langgenius/dify-ui/toast', () => ({ toast: mockToast, })) diff --git a/web/app/components/plugins/plugin-detail-panel/__tests__/endpoint-card.spec.tsx b/web/app/components/plugins/plugin-detail-panel/__tests__/endpoint-card.spec.tsx index 15c231cb17..1dab8bdf84 100644 --- a/web/app/components/plugins/plugin-detail-panel/__tests__/endpoint-card.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/__tests__/endpoint-card.spec.tsx @@ -10,7 +10,7 @@ const mockDeleteEndpoint = vi.fn() const mockUpdateEndpoint = vi.fn() const mockToastNotify = vi.fn() -vi.mock('@/app/components/base/ui/toast', () => ({ +vi.mock('@langgenius/dify-ui/toast', () => ({ toast: Object.assign( (message: string, options?: { type?: string }) => mockToastNotify({ type: options?.type, message }), { diff --git a/web/app/components/plugins/plugin-detail-panel/__tests__/endpoint-modal.spec.tsx b/web/app/components/plugins/plugin-detail-panel/__tests__/endpoint-modal.spec.tsx index 0be17f07d4..7ccd5065e5 100644 --- a/web/app/components/plugins/plugin-detail-panel/__tests__/endpoint-modal.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/__tests__/endpoint-modal.spec.tsx @@ -6,7 +6,7 @@ import EndpointModal from '../endpoint-modal' const mockToastNotify = vi.fn() -vi.mock('@/app/components/base/ui/toast', () => ({ +vi.mock('@langgenius/dify-ui/toast', () => ({ toast: Object.assign( (message: string, options?: { type?: string }) => mockToastNotify({ type: options?.type, message }), { diff --git a/web/app/components/plugins/plugin-detail-panel/__tests__/operation-dropdown.spec.tsx b/web/app/components/plugins/plugin-detail-panel/__tests__/operation-dropdown.spec.tsx index fa3d6f2266..db6ff57957 100644 --- a/web/app/components/plugins/plugin-detail-panel/__tests__/operation-dropdown.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/__tests__/operation-dropdown.spec.tsx @@ -14,7 +14,7 @@ vi.mock('@langgenius/dify-ui/cn', () => ({ cn: (...args: (string | undefined | false | null)[]) => args.filter(Boolean).join(' '), })) -vi.mock('@/app/components/base/ui/dropdown-menu', () => ({ +vi.mock('@langgenius/dify-ui/dropdown-menu', () => ({ DropdownMenu: ({ children, open }: { children: ReactNode, open: boolean }) => (
{children}
), diff --git a/web/app/components/plugins/plugin-detail-panel/datasource-action-list.tsx b/web/app/components/plugins/plugin-detail-panel/datasource-action-list.tsx index cafdb7b4b9..5bba7b823b 100644 --- a/web/app/components/plugins/plugin-detail-panel/datasource-action-list.tsx +++ b/web/app/components/plugins/plugin-detail-panel/datasource-action-list.tsx @@ -1,5 +1,5 @@ // import { useAppContext } from '@/context/app-context' -// import { Button } from '@/app/components/base/ui/button' +// import { Button } from '@langgenius/dify-ui/button' // import Indicator from '@/app/components/header/indicator' // import ToolItem from '@/app/components/tools/provider/tool-item' // import ConfigCredential from '@/app/components/tools/setting/build-in/config-credentials' diff --git a/web/app/components/plugins/plugin-detail-panel/detail-header/__tests__/index.spec.tsx b/web/app/components/plugins/plugin-detail-panel/detail-header/__tests__/index.spec.tsx index 84a61cd643..24838fea59 100644 --- a/web/app/components/plugins/plugin-detail-panel/detail-header/__tests__/index.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/detail-header/__tests__/index.spec.tsx @@ -45,7 +45,7 @@ vi.mock('@/app/components/base/action-button', () => ({ ), })) -vi.mock('@/app/components/base/ui/button', () => ({ +vi.mock('@langgenius/dify-ui/button', () => ({ Button: ({ children, onClick }: { children: React.ReactNode, onClick?: () => void }) => ( ), @@ -57,7 +57,7 @@ vi.mock('@/app/components/base/badge', () => ({ ), })) -vi.mock('@/app/components/base/ui/tooltip', () => ({ +vi.mock('@langgenius/dify-ui/tooltip', () => ({ Tooltip: ({ children }: { children: React.ReactNode }) =>
{children}
, TooltipTrigger: ({ render }: { render: React.ReactNode }) => <>{render}, TooltipContent: ({ children }: { children: React.ReactNode }) =>
{children}
, diff --git a/web/app/components/plugins/plugin-detail-panel/detail-header/components/header-modals.tsx b/web/app/components/plugins/plugin-detail-panel/detail-header/components/header-modals.tsx index 3bf24a19b7..c2f6bb8210 100644 --- a/web/app/components/plugins/plugin-detail-panel/detail-header/components/header-modals.tsx +++ b/web/app/components/plugins/plugin-detail-panel/detail-header/components/header-modals.tsx @@ -3,7 +3,6 @@ import type { FC } from 'react' import type { PluginDetail } from '../../../types' import type { ModalStates, VersionTarget } from '../hooks' -import { useTranslation } from 'react-i18next' import { AlertDialog, AlertDialogActions, @@ -12,7 +11,8 @@ import { AlertDialogContent, AlertDialogDescription, AlertDialogTitle, -} from '@/app/components/base/ui/alert-dialog' +} from '@langgenius/dify-ui/alert-dialog' +import { useTranslation } from 'react-i18next' import PluginInfo from '@/app/components/plugins/plugin-page/plugin-info' import UpdateFromMarketplace from '@/app/components/plugins/update-plugin/from-market-place' import { useGetLanguage } from '@/context/i18n' diff --git a/web/app/components/plugins/plugin-detail-panel/detail-header/hooks/__tests__/use-plugin-operations.spec.ts b/web/app/components/plugins/plugin-detail-panel/detail-header/hooks/__tests__/use-plugin-operations.spec.ts index b8873f1087..855530bf18 100644 --- a/web/app/components/plugins/plugin-detail-panel/detail-header/hooks/__tests__/use-plugin-operations.spec.ts +++ b/web/app/components/plugins/plugin-detail-panel/detail-header/hooks/__tests__/use-plugin-operations.spec.ts @@ -33,7 +33,7 @@ const { } }) -vi.mock('@/app/components/base/ui/toast', () => ({ +vi.mock('@langgenius/dify-ui/toast', () => ({ toast: Object.assign( (message: string, options?: { type?: string }) => mockToastNotify({ type: options?.type, message }), { diff --git a/web/app/components/plugins/plugin-detail-panel/detail-header/hooks/use-plugin-operations.ts b/web/app/components/plugins/plugin-detail-panel/detail-header/hooks/use-plugin-operations.ts index 765c0e8a4e..62c762a35d 100644 --- a/web/app/components/plugins/plugin-detail-panel/detail-header/hooks/use-plugin-operations.ts +++ b/web/app/components/plugins/plugin-detail-panel/detail-header/hooks/use-plugin-operations.ts @@ -2,10 +2,10 @@ import type { PluginDetail } from '../../../types' import type { ModalStates, VersionTarget } from './use-detail-header-state' +import { toast } from '@langgenius/dify-ui/toast' import { useCallback } from 'react' import { useTranslation } from 'react-i18next' import { trackEvent } from '@/app/components/base/amplitude' -import { toast } from '@/app/components/base/ui/toast' import { useModalContext } from '@/context/modal-context' import { useProviderContext } from '@/context/provider-context' import { uninstallPlugin } from '@/service/plugins' diff --git a/web/app/components/plugins/plugin-detail-panel/detail-header/index.tsx b/web/app/components/plugins/plugin-detail-panel/detail-header/index.tsx index 5a89afa2c9..1ed91a93f5 100644 --- a/web/app/components/plugins/plugin-detail-panel/detail-header/index.tsx +++ b/web/app/components/plugins/plugin-detail-panel/detail-header/index.tsx @@ -1,13 +1,13 @@ 'use client' import type { PluginDetail } from '../../types' +import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' +import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { useMemo } from 'react' import { useTranslation } from 'react-i18next' import ActionButton from '@/app/components/base/action-button' import Badge from '@/app/components/base/badge' -import { Button } from '@/app/components/base/ui/button' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/app/components/base/ui/tooltip' import { AuthCategory, PluginAuth } from '@/app/components/plugins/plugin-auth' import OperationDropdown from '@/app/components/plugins/plugin-detail-panel/operation-dropdown' import PluginVersionPicker from '@/app/components/plugins/update-plugin/plugin-version-picker' diff --git a/web/app/components/plugins/plugin-detail-panel/endpoint-card.tsx b/web/app/components/plugins/plugin-detail-panel/endpoint-card.tsx index a8729763ec..e1adc6282d 100644 --- a/web/app/components/plugins/plugin-detail-panel/endpoint-card.tsx +++ b/web/app/components/plugins/plugin-detail-panel/endpoint-card.tsx @@ -1,4 +1,14 @@ import type { EndpointListItem, PluginDetail } from '../types' +import { + AlertDialog, + AlertDialogActions, + AlertDialogCancelButton, + AlertDialogConfirmButton, + AlertDialogContent, + AlertDialogTitle, +} from '@langgenius/dify-ui/alert-dialog' +import { Switch } from '@langgenius/dify-ui/switch' +import { toast } from '@langgenius/dify-ui/toast' import { RiClipboardLine, RiDeleteBinLine, RiEditLine, RiLoginCircleLine } from '@remixicon/react' import { useBoolean } from 'ahooks' import copy from 'copy-to-clipboard' @@ -7,17 +17,7 @@ import { useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import ActionButton from '@/app/components/base/action-button' import { CopyCheck } from '@/app/components/base/icons/src/vender/line/files' -import Switch from '@/app/components/base/switch' import Tooltip from '@/app/components/base/tooltip' -import { - AlertDialog, - AlertDialogActions, - AlertDialogCancelButton, - AlertDialogConfirmButton, - AlertDialogContent, - AlertDialogTitle, -} from '@/app/components/base/ui/alert-dialog' -import { toast } from '@/app/components/base/ui/toast' import Indicator from '@/app/components/header/indicator' import { addDefaultValue, toolCredentialToFormSchemas } from '@/app/components/tools/utils/to-form-schema' import { diff --git a/web/app/components/plugins/plugin-detail-panel/endpoint-list.tsx b/web/app/components/plugins/plugin-detail-panel/endpoint-list.tsx index c3c09db786..2d11305f6e 100644 --- a/web/app/components/plugins/plugin-detail-panel/endpoint-list.tsx +++ b/web/app/components/plugins/plugin-detail-panel/endpoint-list.tsx @@ -1,5 +1,6 @@ import type { PluginDetail } from '@/app/components/plugins/types' import { cn } from '@langgenius/dify-ui/cn' +import { toast } from '@langgenius/dify-ui/toast' import { RiAddLine, RiApps2AddLine, @@ -11,7 +12,6 @@ import { useMemo } from 'react' import { useTranslation } from 'react-i18next' import ActionButton from '@/app/components/base/action-button' import Tooltip from '@/app/components/base/tooltip' -import { toast } from '@/app/components/base/ui/toast' import { toolCredentialToFormSchemas } from '@/app/components/tools/utils/to-form-schema' import { useDocLink } from '@/context/i18n' import { diff --git a/web/app/components/plugins/plugin-detail-panel/endpoint-modal.tsx b/web/app/components/plugins/plugin-detail-panel/endpoint-modal.tsx index 4c694d4293..0fe0fff6df 100644 --- a/web/app/components/plugins/plugin-detail-panel/endpoint-modal.tsx +++ b/web/app/components/plugins/plugin-detail-panel/endpoint-modal.tsx @@ -2,14 +2,14 @@ import type { FC } from 'react' import type { FormSchema } from '../../base/form/types' import type { PluginDetail } from '../types' +import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' +import { toast } from '@langgenius/dify-ui/toast' import { RiArrowRightUpLine, RiCloseLine } from '@remixicon/react' import * as React from 'react' import { useTranslation } from 'react-i18next' import ActionButton from '@/app/components/base/action-button' import Drawer from '@/app/components/base/drawer' -import { Button } from '@/app/components/base/ui/button' -import { toast } from '@/app/components/base/ui/toast' import Form from '@/app/components/header/account-setting/model-provider-page/model-modal/Form' import { useRenderI18nObject } from '@/hooks/use-i18n' import { ReadmeEntrance } from '../readme-panel/entrance' diff --git a/web/app/components/plugins/plugin-detail-panel/model-selector/__tests__/index.spec.tsx b/web/app/components/plugins/plugin-detail-panel/model-selector/__tests__/index.spec.tsx index 107d42ada2..00ed9eb4f1 100644 --- a/web/app/components/plugins/plugin-detail-panel/model-selector/__tests__/index.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/model-selector/__tests__/index.spec.tsx @@ -9,7 +9,7 @@ import ModelParameterModal from '../index' // ==================== Mock Setup ==================== const mockToastNotify = vi.fn() -vi.mock('@/app/components/base/ui/toast', () => ({ +vi.mock('@langgenius/dify-ui/toast', () => ({ toast: Object.assign( (message: string, options?: { type?: string }) => mockToastNotify({ type: options?.type, message }), { diff --git a/web/app/components/plugins/plugin-detail-panel/model-selector/__tests__/tts-params-panel.spec.tsx b/web/app/components/plugins/plugin-detail-panel/model-selector/__tests__/tts-params-panel.spec.tsx index e423359872..1d000f7c35 100644 --- a/web/app/components/plugins/plugin-detail-panel/model-selector/__tests__/tts-params-panel.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/model-selector/__tests__/tts-params-panel.spec.tsx @@ -26,8 +26,8 @@ const MockSelectContext = React.createContext<{ onValueChange: () => {}, }) -vi.mock('@/app/components/base/ui/select', async (importOriginal) => { - const actual = await importOriginal() +vi.mock('@langgenius/dify-ui/select', async (importOriginal) => { + const actual = await importOriginal() return { ...actual, diff --git a/web/app/components/plugins/plugin-detail-panel/model-selector/index.tsx b/web/app/components/plugins/plugin-detail-panel/model-selector/index.tsx index 5f5c0494a9..d0dcab18ea 100644 --- a/web/app/components/plugins/plugin-detail-panel/model-selector/index.tsx +++ b/web/app/components/plugins/plugin-detail-panel/model-selector/index.tsx @@ -9,14 +9,14 @@ import type { } from '@/app/components/header/account-setting/model-provider-page/declarations' import type { TriggerProps } from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal/trigger' import { cn } from '@langgenius/dify-ui/cn' -import { useMemo, useState } from 'react' -import { useTranslation } from 'react-i18next' import { Popover, PopoverContent, PopoverTrigger, -} from '@/app/components/base/ui/popover' -import { toast } from '@/app/components/base/ui/toast' +} from '@langgenius/dify-ui/popover' +import { toast } from '@langgenius/dify-ui/toast' +import { useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' import { ModelStatusEnum, ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' import { useModelList, diff --git a/web/app/components/plugins/plugin-detail-panel/model-selector/tts-params-panel.tsx b/web/app/components/plugins/plugin-detail-panel/model-selector/tts-params-panel.tsx index 1ab7ea2f11..7edf2f5d18 100644 --- a/web/app/components/plugins/plugin-detail-panel/model-selector/tts-params-panel.tsx +++ b/web/app/components/plugins/plugin-detail-panel/model-selector/tts-params-panel.tsx @@ -1,7 +1,7 @@ +import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger, SelectValue } from '@langgenius/dify-ui/select' import * as React from 'react' import { useMemo } from 'react' import { useTranslation } from 'react-i18next' -import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger, SelectValue } from '@/app/components/base/ui/select' import { languages } from '@/i18n-config/language' type Props = { diff --git a/web/app/components/plugins/plugin-detail-panel/operation-dropdown.tsx b/web/app/components/plugins/plugin-detail-panel/operation-dropdown.tsx index 4b36e4612c..c7a1529a3e 100644 --- a/web/app/components/plugins/plugin-detail-panel/operation-dropdown.tsx +++ b/web/app/components/plugins/plugin-detail-panel/operation-dropdown.tsx @@ -1,16 +1,16 @@ 'use client' +import type { Placement } from '@langgenius/dify-ui/dropdown-menu' import type { FC } from 'react' -import type { Placement } from '@/app/components/base/ui/placement' import { cn } from '@langgenius/dify-ui/cn' -import * as React from 'react' -import { useTranslation } from 'react-i18next' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, -} from '@/app/components/base/ui/dropdown-menu' +} from '@langgenius/dify-ui/dropdown-menu' +import * as React from 'react' +import { useTranslation } from 'react-i18next' import { useGlobalPublicStore } from '@/context/global-public-context' import { PluginSource } from '../types' diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/delete-confirm.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/delete-confirm.spec.tsx index f083c24f42..c2cc608b45 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/delete-confirm.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/delete-confirm.spec.tsx @@ -15,8 +15,8 @@ vi.mock('@/service/use-triggers', () => ({ useDeleteTriggerSubscription: () => ({ mutate: mockDelete, isPending: false }), })) -vi.mock('@/app/components/base/ui/toast', async (importOriginal) => { - const actual = await importOriginal() +vi.mock('@langgenius/dify-ui/toast', async (importOriginal) => { + const actual = await importOriginal() return { ...actual, toast: { diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/log-viewer.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/log-viewer.spec.tsx index 351c1f9d2d..3594c10ce2 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/log-viewer.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/log-viewer.spec.tsx @@ -6,7 +6,7 @@ import LogViewer from '../log-viewer' const mockToastNotify = vi.fn() const mockWriteText = vi.fn() -vi.mock('@/app/components/base/ui/toast', () => ({ +vi.mock('@langgenius/dify-ui/toast', () => ({ toast: Object.assign( (message: string, options?: { type?: string }) => mockToastNotify({ type: options?.type, message }), { diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/selector-entry.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/selector-entry.spec.tsx index 3c4ff83fc8..37d828591f 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/selector-entry.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/selector-entry.spec.tsx @@ -26,7 +26,7 @@ vi.mock('@/service/use-triggers', () => ({ useDeleteTriggerSubscription: () => ({ mutate: vi.fn(), isPending: false }), })) -vi.mock('@/app/components/base/ui/toast', () => ({ +vi.mock('@langgenius/dify-ui/toast', () => ({ toast: Object.assign(vi.fn(), { success: vi.fn(), error: vi.fn(), diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/selector-view.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/selector-view.spec.tsx index 44cec53e28..2256e90e9d 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/selector-view.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/selector-view.spec.tsx @@ -25,7 +25,7 @@ vi.mock('@/service/use-triggers', () => ({ useDeleteTriggerSubscription: () => ({ mutate: mockDelete, isPending: false }), })) -vi.mock('@/app/components/base/ui/toast', () => ({ +vi.mock('@langgenius/dify-ui/toast', () => ({ toast: Object.assign(vi.fn(), { success: vi.fn(), error: vi.fn(), diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/subscription-card.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/subscription-card.spec.tsx index 19edb65dfb..9a89aab7cf 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/subscription-card.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/subscription-card.spec.tsx @@ -29,7 +29,7 @@ vi.mock('@/service/use-triggers', () => ({ useDeleteTriggerSubscription: () => ({ mutate: vi.fn(), isPending: false }), })) -vi.mock('@/app/components/base/ui/toast', () => ({ +vi.mock('@langgenius/dify-ui/toast', () => ({ toast: Object.assign(vi.fn(), { success: vi.fn(), error: vi.fn(), diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/__tests__/common-modal.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/__tests__/common-modal.spec.tsx index 72532ea38d..459b657f3f 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/__tests__/common-modal.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/__tests__/common-modal.spec.tsx @@ -122,7 +122,7 @@ vi.mock('@/utils/urlValidation', () => ({ })) const mockToastNotify = vi.fn() -vi.mock('@/app/components/base/ui/toast', () => ({ +vi.mock('@langgenius/dify-ui/toast', () => ({ toast: Object.assign((params: unknown) => mockToastNotify(params), { success: (message: unknown) => mockToastNotify({ type: 'success', message }), error: (message: unknown) => mockToastNotify({ type: 'error', message }), diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/__tests__/index.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/__tests__/index.spec.tsx index 0714fbf554..e1615e7b0a 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/__tests__/index.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/__tests__/index.spec.tsx @@ -1,8 +1,8 @@ import type { SimpleDetail } from '../../../store' import type { TriggerOAuthConfig, TriggerProviderApiEntity, TriggerSubscription, TriggerSubscriptionBuilder } from '@/app/components/workflow/block-selector/types' +import { toast } from '@langgenius/dify-ui/toast' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { toast } from '@/app/components/base/ui/toast' import { SupportedCreationMethods } from '@/app/components/plugins/types' import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types' import { CreateButtonType, CreateSubscriptionButton, DEFAULT_METHOD } from '../index' @@ -34,7 +34,7 @@ vi.mock('@/app/components/base/portal-to-follow-elem', () => ({ }, })) -vi.mock('@/app/components/base/ui/toast', () => ({ +vi.mock('@langgenius/dify-ui/toast', () => ({ toast: Object.assign(vi.fn(), { success: vi.fn(), error: vi.fn(), diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/__tests__/oauth-client.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/__tests__/oauth-client.spec.tsx index 5c4407b3c5..46b9499027 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/__tests__/oauth-client.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/__tests__/oauth-client.spec.tsx @@ -86,7 +86,7 @@ vi.mock('@/hooks/use-oauth', () => ({ })) const mockToastNotify = vi.fn() -vi.mock('@/app/components/base/ui/toast', () => ({ +vi.mock('@langgenius/dify-ui/toast', () => ({ toast: Object.assign( (message: string, options?: { type?: string }) => mockToastNotify({ type: options?.type, message }), { diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/__tests__/use-common-modal-state.helpers.spec.ts b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/__tests__/use-common-modal-state.helpers.spec.ts index 61482e2912..87db85b56e 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/__tests__/use-common-modal-state.helpers.spec.ts +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/__tests__/use-common-modal-state.helpers.spec.ts @@ -27,7 +27,7 @@ const { mockIsPrivateOrLocalAddress: vi.fn(), })) -vi.mock('@/app/components/base/ui/toast', () => ({ +vi.mock('@langgenius/dify-ui/toast', () => ({ toast: { error: mockToastError, }, diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/__tests__/use-common-modal-state.spec.ts b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/__tests__/use-common-modal-state.spec.ts index 399d3ba60c..26e4a69baa 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/__tests__/use-common-modal-state.spec.ts +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/__tests__/use-common-modal-state.spec.ts @@ -87,7 +87,7 @@ vi.mock('@/service/use-triggers', () => ({ })) const mockToastNotify = vi.fn() -vi.mock('@/app/components/base/ui/toast', () => ({ +vi.mock('@langgenius/dify-ui/toast', () => ({ toast: { success: (message: string) => mockToastNotify({ type: 'success', message }), error: (message: string) => mockToastNotify({ type: 'error', message }), diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/__tests__/use-oauth-client-state.spec.ts b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/__tests__/use-oauth-client-state.spec.ts index cebfc947e7..82eddf501d 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/__tests__/use-oauth-client-state.spec.ts +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/__tests__/use-oauth-client-state.spec.ts @@ -77,7 +77,7 @@ vi.mock('@/hooks/use-oauth', () => ({ })) const mockToastNotify = vi.fn() -vi.mock('@/app/components/base/ui/toast', () => ({ +vi.mock('@langgenius/dify-ui/toast', () => ({ toast: Object.assign( (message: string, options?: { type?: string }) => mockToastNotify({ type: options?.type, message }), { diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/use-common-modal-state.helpers.ts b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/use-common-modal-state.helpers.ts index 8df864c4fa..2754bbf39d 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/use-common-modal-state.helpers.ts +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/use-common-modal-state.helpers.ts @@ -3,8 +3,8 @@ import type { Dispatch, SetStateAction } from 'react' import type { FormRefObject } from '@/app/components/base/form/types' import type { TriggerSubscriptionBuilder } from '@/app/components/workflow/block-selector/types' import type { BuildTriggerSubscriptionPayload } from '@/service/use-triggers' +import { toast } from '@langgenius/dify-ui/toast' import { useEffect, useRef } from 'react' -import { toast } from '@/app/components/base/ui/toast' import { SupportedCreationMethods } from '@/app/components/plugins/types' import { isPrivateOrLocalAddress } from '@/utils/urlValidation' diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/use-common-modal-state.ts b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/use-common-modal-state.ts index 29613c6f4f..0f40da2d58 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/use-common-modal-state.ts +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/use-common-modal-state.ts @@ -3,10 +3,10 @@ import type { SimpleDetail } from '../../../store' import type { SchemaItem } from '../components/modal-steps' import type { FormRefObject } from '@/app/components/base/form/types' import type { TriggerLogEntity, TriggerSubscriptionBuilder } from '@/app/components/workflow/block-selector/types' +import { toast } from '@langgenius/dify-ui/toast' import { debounce } from 'es-toolkit/compat' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { toast } from '@/app/components/base/ui/toast' import { SupportedCreationMethods } from '@/app/components/plugins/types' import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types' import { diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/use-oauth-client-state.ts b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/use-oauth-client-state.ts index e5a5ded9df..25058e529c 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/use-oauth-client-state.ts +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/use-oauth-client-state.ts @@ -2,9 +2,9 @@ import type { FormRefObject } from '@/app/components/base/form/types' import type { TriggerOAuthClientParams, TriggerOAuthConfig, TriggerSubscriptionBuilder } from '@/app/components/workflow/block-selector/types' import type { ConfigureTriggerOAuthPayload } from '@/service/use-triggers' +import { toast } from '@langgenius/dify-ui/toast' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { toast } from '@/app/components/base/ui/toast' import { openOAuthPopup } from '@/hooks/use-oauth' import { useConfigureTriggerOAuth, diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/index.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/index.tsx index 4861f30934..b007c89cb9 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/index.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/index.tsx @@ -1,6 +1,8 @@ import type { Option } from '@/app/components/base/select/custom' import type { TriggerSubscriptionBuilder } from '@/app/components/workflow/block-selector/types' +import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' +import { toast } from '@langgenius/dify-ui/toast' import { RiAddLine, RiEqualizer2Line } from '@remixicon/react' import { useBoolean } from 'ahooks' import { useCallback, useMemo, useState } from 'react' @@ -9,8 +11,6 @@ import { ActionButton, ActionButtonState } from '@/app/components/base/action-bu import Badge from '@/app/components/base/badge' import CustomSelect from '@/app/components/base/select/custom' import Tooltip from '@/app/components/base/tooltip' -import { Button } from '@/app/components/base/ui/button' -import { toast } from '@/app/components/base/ui/toast' import { openOAuthPopup } from '@/hooks/use-oauth' import { useInitiateTriggerOAuth, useTriggerOAuthConfig, useTriggerProviderInfo } from '@/service/use-triggers' import { SupportedCreationMethods } from '../../../types' diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/oauth-client.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/oauth-client.tsx index c4ae63ab66..450324ae40 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/oauth-client.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/oauth-client.tsx @@ -1,5 +1,7 @@ 'use client' import type { TriggerOAuthConfig, TriggerSubscriptionBuilder } from '@/app/components/workflow/block-selector/types' +import { Button } from '@langgenius/dify-ui/button' +import { toast } from '@langgenius/dify-ui/toast' import { RiClipboardLine, RiInformation2Fill, @@ -7,8 +9,6 @@ import { import { useTranslation } from 'react-i18next' import { BaseForm } from '@/app/components/base/form/components/base' import Modal from '@/app/components/base/modal/modal' -import { Button } from '@/app/components/base/ui/button' -import { toast } from '@/app/components/base/ui/toast' import OptionCard from '@/app/components/workflow/nodes/_base/components/option-card' import { usePluginStore } from '../../store' import { ClientTypeEnum, useOAuthClientState } from './hooks/use-oauth-client-state' diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/delete-confirm.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/delete-confirm.tsx index a360a477e3..3599dc6260 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/delete-confirm.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/delete-confirm.tsx @@ -1,6 +1,3 @@ -import { useState } from 'react' -import { useTranslation } from 'react-i18next' -import Input from '@/app/components/base/input' import { AlertDialog, AlertDialogActions, @@ -9,8 +6,11 @@ import { AlertDialogContent, AlertDialogDescription, AlertDialogTitle, -} from '@/app/components/base/ui/alert-dialog' -import { toast } from '@/app/components/base/ui/toast' +} from '@langgenius/dify-ui/alert-dialog' +import { toast } from '@langgenius/dify-ui/toast' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import Input from '@/app/components/base/input' import { useDeleteTriggerSubscription } from '@/service/use-triggers' import { useSubscriptionList } from './use-subscription-list' diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/__tests__/apikey-edit-modal.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/__tests__/apikey-edit-modal.spec.tsx index b33f0af8e9..6e192e025c 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/__tests__/apikey-edit-modal.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/__tests__/apikey-edit-modal.spec.tsx @@ -47,7 +47,7 @@ vi.mock('@/service/use-triggers', () => ({ useTriggerPluginDynamicOptions: () => ({ data: [], isLoading: false }), })) -vi.mock('@/app/components/base/ui/toast', () => ({ +vi.mock('@langgenius/dify-ui/toast', () => ({ toast: Object.assign((args: { type: string, message: string }) => mockToast(args), { success: (message: string) => mockToast({ type: 'success', message }), error: (message: string) => mockToast({ type: 'error', message }), diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/__tests__/index.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/__tests__/index.spec.tsx index 126d8e366d..6cf58b8972 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/__tests__/index.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/__tests__/index.spec.tsx @@ -13,7 +13,7 @@ import { OAuthEditModal } from '../oauth-edit-modal' // ==================== Mock Setup ==================== const mockToastNotify = vi.fn() -vi.mock('@/app/components/base/ui/toast', () => ({ +vi.mock('@langgenius/dify-ui/toast', () => ({ toast: Object.assign((message: string, options?: { type?: string }) => mockToastNotify({ type: options?.type, message }), { success: (message: string) => mockToastNotify({ type: 'success', message }), error: (message: string) => mockToastNotify({ type: 'error', message }), diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/__tests__/manual-edit-modal.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/__tests__/manual-edit-modal.spec.tsx index 4fa236783b..e5fa43268a 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/__tests__/manual-edit-modal.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/__tests__/manual-edit-modal.spec.tsx @@ -30,7 +30,7 @@ vi.mock('@/service/use-triggers', () => ({ useTriggerPluginDynamicOptions: () => ({ data: [], isLoading: false }), })) -vi.mock('@/app/components/base/ui/toast', () => ({ +vi.mock('@langgenius/dify-ui/toast', () => ({ toast: Object.assign((args: { type: string, message: string }) => mockToast(args), { success: (message: string) => mockToast({ type: 'success', message }), error: (message: string) => mockToast({ type: 'error', message }), diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/__tests__/oauth-edit-modal.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/__tests__/oauth-edit-modal.spec.tsx index 1927ae5a43..c860cd2818 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/__tests__/oauth-edit-modal.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/__tests__/oauth-edit-modal.spec.tsx @@ -30,7 +30,7 @@ vi.mock('@/service/use-triggers', () => ({ useTriggerPluginDynamicOptions: () => ({ data: [], isLoading: false }), })) -vi.mock('@/app/components/base/ui/toast', () => ({ +vi.mock('@langgenius/dify-ui/toast', () => ({ toast: Object.assign((args: { type: string, message: string }) => mockToast(args), { success: (message: string) => mockToast({ type: 'success', message }), error: (message: string) => mockToast({ type: 'error', message }), diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/apikey-edit-modal.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/apikey-edit-modal.tsx index eea9cb7ff0..f191ad41a8 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/apikey-edit-modal.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/apikey-edit-modal.tsx @@ -2,6 +2,7 @@ import type { FormRefObject, FormSchema } from '@/app/components/base/form/types' import type { ParametersSchema, PluginDetail } from '@/app/components/plugins/types' import type { TriggerSubscription } from '@/app/components/workflow/block-selector/types' +import { toast } from '@langgenius/dify-ui/toast' import { isEqual } from 'es-toolkit/predicate' import { useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -9,7 +10,6 @@ import { EncryptedBottom } from '@/app/components/base/encrypted-bottom' import { BaseForm } from '@/app/components/base/form/components/base' import { FormTypeEnum } from '@/app/components/base/form/types' import Modal from '@/app/components/base/modal/modal' -import { toast } from '@/app/components/base/ui/toast' import { ReadmeEntrance } from '@/app/components/plugins/readme-panel/entrance' import { useUpdateTriggerSubscription, useVerifyTriggerSubscription } from '@/service/use-triggers' import { parsePluginErrorMessage } from '@/utils/error-parser' diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/manual-edit-modal.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/manual-edit-modal.tsx index 333a800ffd..3b3fa1082f 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/manual-edit-modal.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/manual-edit-modal.tsx @@ -2,13 +2,13 @@ import type { FormRefObject, FormSchema } from '@/app/components/base/form/types' import type { ParametersSchema, PluginDetail } from '@/app/components/plugins/types' import type { TriggerSubscription } from '@/app/components/workflow/block-selector/types' +import { toast } from '@langgenius/dify-ui/toast' import { isEqual } from 'es-toolkit/predicate' import { useMemo, useRef } from 'react' import { useTranslation } from 'react-i18next' import { BaseForm } from '@/app/components/base/form/components/base' import { FormTypeEnum } from '@/app/components/base/form/types' import Modal from '@/app/components/base/modal/modal' -import { toast } from '@/app/components/base/ui/toast' import { ReadmeEntrance } from '@/app/components/plugins/readme-panel/entrance' import { useUpdateTriggerSubscription } from '@/service/use-triggers' import { ReadmeShowType } from '../../../readme-panel/store' diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/oauth-edit-modal.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/oauth-edit-modal.tsx index 3937d82a0e..355a132bd2 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/oauth-edit-modal.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/oauth-edit-modal.tsx @@ -2,13 +2,13 @@ import type { FormRefObject, FormSchema } from '@/app/components/base/form/types' import type { ParametersSchema, PluginDetail } from '@/app/components/plugins/types' import type { TriggerSubscription } from '@/app/components/workflow/block-selector/types' +import { toast } from '@langgenius/dify-ui/toast' import { isEqual } from 'es-toolkit/predicate' import { useMemo, useRef } from 'react' import { useTranslation } from 'react-i18next' import { BaseForm } from '@/app/components/base/form/components/base' import { FormTypeEnum } from '@/app/components/base/form/types' import Modal from '@/app/components/base/modal/modal' -import { toast } from '@/app/components/base/ui/toast' import { ReadmeEntrance } from '@/app/components/plugins/readme-panel/entrance' import { useUpdateTriggerSubscription } from '@/service/use-triggers' import { ReadmeShowType } from '../../../readme-panel/store' diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/log-viewer.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/log-viewer.tsx index 1960103822..b0b3efe853 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/log-viewer.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/log-viewer.tsx @@ -1,6 +1,7 @@ 'use client' import type { TriggerLogEntity } from '@/app/components/workflow/block-selector/types' import { cn } from '@langgenius/dify-ui/cn' +import { toast } from '@langgenius/dify-ui/toast' import { RiArrowDownSLine, RiArrowRightSLine, @@ -12,7 +13,6 @@ import dayjs from 'dayjs' import * as React from 'react' import { useState } from 'react' import { useTranslation } from 'react-i18next' -import { toast } from '@/app/components/base/ui/toast' import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor' import { CodeLanguage } from '@/app/components/workflow/nodes/code/types' diff --git a/web/app/components/plugins/plugin-detail-panel/tool-selector/__tests__/index.spec.tsx b/web/app/components/plugins/plugin-detail-panel/tool-selector/__tests__/index.spec.tsx index 537e99d733..168e4f1eba 100644 --- a/web/app/components/plugins/plugin-detail-panel/tool-selector/__tests__/index.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/tool-selector/__tests__/index.spec.tsx @@ -298,7 +298,7 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/model-modal // Mock Toast - need to track notify calls for assertions const mockToastNotify = vi.fn() -vi.mock('@/app/components/base/ui/toast', () => ({ +vi.mock('@langgenius/dify-ui/toast', () => ({ toast: Object.assign((message: string, options?: { type?: string }) => mockToastNotify({ type: options?.type, message }), { success: (message: string) => mockToastNotify({ type: 'success', message }), error: (message: string) => mockToastNotify({ type: 'error', message }), diff --git a/web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/reasoning-config-form.spec.tsx b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/reasoning-config-form.spec.tsx index b4bfd22405..beab35595c 100644 --- a/web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/reasoning-config-form.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/reasoning-config-form.spec.tsx @@ -30,8 +30,8 @@ vi.mock('@/app/components/base/select', () => ({ ), })) -vi.mock('@/app/components/base/switch', () => ({ - default: ({ checked, onCheckedChange }: { checked: boolean, onCheckedChange: (checked: boolean) => void }) => ( +vi.mock('@langgenius/dify-ui/switch', () => ({ + Switch: ({ checked, onCheckedChange }: { checked: boolean, onCheckedChange: (checked: boolean) => void }) => ( diff --git a/web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/tool-credentials-form.spec.tsx b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/tool-credentials-form.spec.tsx index bd2bc0dd5c..2d50a2d2bf 100644 --- a/web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/tool-credentials-form.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/tool-credentials-form.spec.tsx @@ -10,7 +10,7 @@ vi.mock('@langgenius/dify-ui/cn', () => ({ cn: (...args: unknown[]) => args.filter(Boolean).join(' '), })) -vi.mock('@/app/components/base/ui/toast', () => ({ +vi.mock('@langgenius/dify-ui/toast', () => ({ toast: Object.assign(vi.fn(), { success: vi.fn(), error: vi.fn(), diff --git a/web/app/components/plugins/plugin-detail-panel/tool-selector/components/reasoning-config-form.tsx b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/reasoning-config-form.tsx index 4e6be7d81c..1c731d5eca 100644 --- a/web/app/components/plugins/plugin-detail-panel/tool-selector/components/reasoning-config-form.tsx +++ b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/reasoning-config-form.tsx @@ -7,6 +7,7 @@ import type { ValueSelector, } from '@/app/components/workflow/types' import { cn } from '@langgenius/dify-ui/cn' +import { Switch } from '@langgenius/dify-ui/switch' import { RiArrowRightUpLine, RiBracesLine, @@ -16,7 +17,6 @@ import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import Input from '@/app/components/base/input' import { SimpleSelect } from '@/app/components/base/select' -import Switch from '@/app/components/base/switch' import Tooltip from '@/app/components/base/tooltip' import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks' diff --git a/web/app/components/plugins/plugin-detail-panel/tool-selector/components/tool-credentials-form.tsx b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/tool-credentials-form.tsx index 90893c88ec..64068166d0 100644 --- a/web/app/components/plugins/plugin-detail-panel/tool-selector/components/tool-credentials-form.tsx +++ b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/tool-credentials-form.tsx @@ -2,7 +2,9 @@ import type { FC } from 'react' import type { Collection } from '@/app/components/tools/types' import type { ToolCredentialFormSchema } from '@/app/components/tools/utils/to-form-schema' +import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' +import { toast } from '@langgenius/dify-ui/toast' import { RiArrowRightUpLine, } from '@remixicon/react' @@ -10,8 +12,6 @@ import * as React from 'react' import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import Loading from '@/app/components/base/loading' -import { Button } from '@/app/components/base/ui/button' -import { toast } from '@/app/components/base/ui/toast' import Form from '@/app/components/header/account-setting/model-provider-page/model-modal/Form' import { addDefaultValue, toolCredentialToFormSchemas } from '@/app/components/tools/utils/to-form-schema' import { useRenderI18nObject } from '@/hooks/use-i18n' diff --git a/web/app/components/plugins/plugin-detail-panel/tool-selector/components/tool-item.tsx b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/tool-item.tsx index 3ba30790af..889243d507 100644 --- a/web/app/components/plugins/plugin-detail-panel/tool-selector/components/tool-item.tsx +++ b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/tool-item.tsx @@ -1,5 +1,7 @@ 'use client' +import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' +import { Switch } from '@langgenius/dify-ui/switch' import { RiDeleteBinLine, RiEqualizer2Line, @@ -11,10 +13,8 @@ import { useTranslation } from 'react-i18next' import ActionButton from '@/app/components/base/action-button' import AppIcon from '@/app/components/base/app-icon' import { Group } from '@/app/components/base/icons/src/vender/other' -import Switch from '@/app/components/base/switch' import Tooltip from '@/app/components/base/tooltip' import { ToolTipContent } from '@/app/components/base/tooltip/content' -import { Button } from '@/app/components/base/ui/button' import Indicator from '@/app/components/header/indicator' import { InstallPluginButton } from '@/app/components/workflow/nodes/_base/components/install-plugin-button' import { useMCPToolAvailability } from '@/app/components/workflow/nodes/_base/components/mcp-tool-availability' diff --git a/web/app/components/plugins/plugin-item/__tests__/action.spec.tsx b/web/app/components/plugins/plugin-item/__tests__/action.spec.tsx index 0b0d9c7fc8..b0ecc839b3 100644 --- a/web/app/components/plugins/plugin-item/__tests__/action.spec.tsx +++ b/web/app/components/plugins/plugin-item/__tests__/action.spec.tsx @@ -26,7 +26,7 @@ const { mockToastNotify: vi.fn(), })) -vi.mock('@/app/components/base/ui/toast', () => ({ +vi.mock('@langgenius/dify-ui/toast', () => ({ toast: Object.assign( (message: string, options?: { type?: string }) => mockToastNotify({ type: options?.type, message }), { diff --git a/web/app/components/plugins/plugin-item/action.tsx b/web/app/components/plugins/plugin-item/action.tsx index ed401b534f..fe0ba932fc 100644 --- a/web/app/components/plugins/plugin-item/action.tsx +++ b/web/app/components/plugins/plugin-item/action.tsx @@ -2,11 +2,6 @@ import type { FC } from 'react' import type { MetaData } from '../types' import type { PluginCategoryEnum } from '@/app/components/plugins/types' -import { RiDeleteBinLine, RiInformation2Line, RiLoopLeftLine } from '@remixicon/react' -import { useBoolean } from 'ahooks' -import * as React from 'react' -import { useCallback } from 'react' -import { useTranslation } from 'react-i18next' import { AlertDialog, AlertDialogActions, @@ -14,8 +9,13 @@ import { AlertDialogConfirmButton, AlertDialogContent, AlertDialogTitle, -} from '@/app/components/base/ui/alert-dialog' -import { toast } from '@/app/components/base/ui/toast' +} from '@langgenius/dify-ui/alert-dialog' +import { toast } from '@langgenius/dify-ui/toast' +import { RiDeleteBinLine, RiInformation2Line, RiLoopLeftLine } from '@remixicon/react' +import { useBoolean } from 'ahooks' +import * as React from 'react' +import { useCallback } from 'react' +import { useTranslation } from 'react-i18next' import { useModalContext } from '@/context/modal-context' import { uninstallPlugin } from '@/service/plugins' import { useInvalidateInstalledPluginList } from '@/service/use-plugins' diff --git a/web/app/components/plugins/plugin-mutation-model/index.tsx b/web/app/components/plugins/plugin-mutation-model/index.tsx index 4e8478f8ad..96a60fd938 100644 --- a/web/app/components/plugins/plugin-mutation-model/index.tsx +++ b/web/app/components/plugins/plugin-mutation-model/index.tsx @@ -1,10 +1,10 @@ import type { UseMutationResult } from '@tanstack/react-query' import type { FC, ReactNode } from 'react' import type { Plugin } from '../types' +import { Button } from '@langgenius/dify-ui/button' import * as React from 'react' import { memo } from 'react' import Modal from '@/app/components/base/modal' -import { Button } from '@/app/components/base/ui/button' import Card from '@/app/components/plugins/card' type Props = { diff --git a/web/app/components/plugins/plugin-page/__tests__/debug-info.spec.tsx b/web/app/components/plugins/plugin-page/__tests__/debug-info.spec.tsx index 2af44ecf97..65a36f4009 100644 --- a/web/app/components/plugins/plugin-page/__tests__/debug-info.spec.tsx +++ b/web/app/components/plugins/plugin-page/__tests__/debug-info.spec.tsx @@ -15,7 +15,7 @@ vi.mock('@/service/use-plugins', () => ({ useDebugKey: () => mockDebugKey, })) -vi.mock('@/app/components/base/ui/button', () => ({ +vi.mock('@langgenius/dify-ui/button', () => ({ Button: ({ children }: { children: React.ReactNode }) => , })) diff --git a/web/app/components/plugins/plugin-page/__tests__/install-plugin-dropdown.spec.tsx b/web/app/components/plugins/plugin-page/__tests__/install-plugin-dropdown.spec.tsx index 1f249b16c6..2dd884e18b 100644 --- a/web/app/components/plugins/plugin-page/__tests__/install-plugin-dropdown.spec.tsx +++ b/web/app/components/plugins/plugin-page/__tests__/install-plugin-dropdown.spec.tsx @@ -35,13 +35,13 @@ vi.mock('@/app/components/base/icons/src/vender/solid/mediaAndDevices', () => ({ MagicBox: () => magic, })) -vi.mock('@/app/components/base/ui/button', () => ({ +vi.mock('@langgenius/dify-ui/button', () => ({ Button: ({ children, onClick, className, ...props }: React.ButtonHTMLAttributes) => ( ), })) -vi.mock('@/app/components/base/ui/dropdown-menu', async () => { +vi.mock('@langgenius/dify-ui/dropdown-menu', async () => { const React = await import('react') const DropdownMenuContext = React.createContext<{ isOpen: boolean, setOpen: (open: boolean) => void } | null>(null) diff --git a/web/app/components/plugins/plugin-page/__tests__/use-reference-setting.spec.ts b/web/app/components/plugins/plugin-page/__tests__/use-reference-setting.spec.ts index c3c415bb4b..efb31b5afb 100644 --- a/web/app/components/plugins/plugin-page/__tests__/use-reference-setting.spec.ts +++ b/web/app/components/plugins/plugin-page/__tests__/use-reference-setting.spec.ts @@ -1,7 +1,7 @@ +// Import mocks for assertions +import { toast } from '@langgenius/dify-ui/toast' import { renderHook, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -// Import mocks for assertions -import { toast } from '@/app/components/base/ui/toast' import { useAppContext } from '@/context/app-context' import { useGlobalPublicStore } from '@/context/global-public-context' diff --git a/web/app/components/plugins/plugin-page/debug-info.tsx b/web/app/components/plugins/plugin-page/debug-info.tsx index 4b3590f15c..4e02fdc2be 100644 --- a/web/app/components/plugins/plugin-page/debug-info.tsx +++ b/web/app/components/plugins/plugin-page/debug-info.tsx @@ -1,5 +1,6 @@ 'use client' import type { FC } from 'react' +import { Button } from '@langgenius/dify-ui/button' import { RiArrowRightUpLine, RiBugLine, @@ -7,7 +8,6 @@ import { import * as React from 'react' import { useTranslation } from 'react-i18next' import Tooltip from '@/app/components/base/tooltip' -import { Button } from '@/app/components/base/ui/button' import { useDocLink } from '@/context/i18n' import { useDebugKey } from '@/service/use-plugins' import KeyValueItem from '../base/key-value-item' diff --git a/web/app/components/plugins/plugin-page/empty/index.tsx b/web/app/components/plugins/plugin-page/empty/index.tsx index 5141519e35..e90918b4ab 100644 --- a/web/app/components/plugins/plugin-page/empty/index.tsx +++ b/web/app/components/plugins/plugin-page/empty/index.tsx @@ -1,4 +1,5 @@ 'use client' +import { Button } from '@langgenius/dify-ui/button' import { noop } from 'es-toolkit/function' import * as React from 'react' import { useEffect, useMemo, useRef, useState } from 'react' @@ -7,7 +8,6 @@ import { Group } from '@/app/components/base/icons/src/vender/other' import { FileZip } from '@/app/components/base/icons/src/vender/solid/files' import { Github } from '@/app/components/base/icons/src/vender/solid/general' import { MagicBox } from '@/app/components/base/icons/src/vender/solid/mediaAndDevices' -import { Button } from '@/app/components/base/ui/button' import InstallFromGitHub from '@/app/components/plugins/install-plugin/install-from-github' import InstallFromLocalPackage from '@/app/components/plugins/install-plugin/install-from-local-package' import { SUPPORT_INSTALL_LOCAL_FILE_EXTENSIONS } from '@/config' diff --git a/web/app/components/plugins/plugin-page/index.tsx b/web/app/components/plugins/plugin-page/index.tsx index 05a6dd26e3..f80d37189a 100644 --- a/web/app/components/plugins/plugin-page/index.tsx +++ b/web/app/components/plugins/plugin-page/index.tsx @@ -2,6 +2,7 @@ import type { Dependency, PluginDeclaration, PluginManifestInMarket } from '../types' import type { PluginPageTab } from './context' +import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { RiBookOpenLine, @@ -14,7 +15,6 @@ import { useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import TabSlider from '@/app/components/base/tab-slider' import Tooltip from '@/app/components/base/tooltip' -import { Button } from '@/app/components/base/ui/button' import ReferenceSettingModal from '@/app/components/plugins/reference-setting-modal' import { MARKETPLACE_API_PREFIX, SUPPORT_INSTALL_LOCAL_FILE_EXTENSIONS } from '@/config' import { useGlobalPublicStore } from '@/context/global-public-context' diff --git a/web/app/components/plugins/plugin-page/install-plugin-dropdown.tsx b/web/app/components/plugins/plugin-page/install-plugin-dropdown.tsx index 9b98d16410..dee3dc17bb 100644 --- a/web/app/components/plugins/plugin-page/install-plugin-dropdown.tsx +++ b/web/app/components/plugins/plugin-page/install-plugin-dropdown.tsx @@ -1,6 +1,13 @@ 'use client' +import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@langgenius/dify-ui/dropdown-menu' import { RiAddLine, RiArrowDownSLine } from '@remixicon/react' import { noop } from 'es-toolkit/function' import { useEffect, useRef, useState } from 'react' @@ -8,13 +15,6 @@ import { useTranslation } from 'react-i18next' import { FileZip } from '@/app/components/base/icons/src/vender/solid/files' import { Github } from '@/app/components/base/icons/src/vender/solid/general' import { MagicBox } from '@/app/components/base/icons/src/vender/solid/mediaAndDevices' -import { Button } from '@/app/components/base/ui/button' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '@/app/components/base/ui/dropdown-menu' import InstallFromGitHub from '@/app/components/plugins/install-plugin/install-from-github' import InstallFromLocalPackage from '@/app/components/plugins/install-plugin/install-from-local-package' import { SUPPORT_INSTALL_LOCAL_FILE_EXTENSIONS } from '@/config' diff --git a/web/app/components/plugins/plugin-page/plugin-tasks/components/error-plugin-item.tsx b/web/app/components/plugins/plugin-page/plugin-tasks/components/error-plugin-item.tsx index 0129c65ee2..ab17dd202b 100644 --- a/web/app/components/plugins/plugin-page/plugin-tasks/components/error-plugin-item.tsx +++ b/web/app/components/plugins/plugin-page/plugin-tasks/components/error-plugin-item.tsx @@ -1,9 +1,9 @@ import type { FC } from 'react' import type { Plugin, PluginStatus } from '@/app/components/plugins/types' import type { Locale } from '@/i18n-config' +import { Button } from '@langgenius/dify-ui/button' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' -import { Button } from '@/app/components/base/ui/button' import InstallFromMarketplace from '@/app/components/plugins/install-plugin/install-from-marketplace' import { PluginSource } from '@/app/components/plugins/types' import { fetchPluginInfoFromMarketPlace } from '@/service/plugins' diff --git a/web/app/components/plugins/plugin-page/plugin-tasks/components/plugin-task-list.tsx b/web/app/components/plugins/plugin-page/plugin-tasks/components/plugin-task-list.tsx index e0dcc5b6f8..24fcf85cde 100644 --- a/web/app/components/plugins/plugin-page/plugin-tasks/components/plugin-task-list.tsx +++ b/web/app/components/plugins/plugin-page/plugin-tasks/components/plugin-task-list.tsx @@ -1,7 +1,7 @@ import type { FC } from 'react' import type { PluginStatus } from '@/app/components/plugins/types' +import { Button } from '@langgenius/dify-ui/button' import { useTranslation } from 'react-i18next' -import { Button } from '@/app/components/base/ui/button' import { useGetLanguage } from '@/context/i18n' import ErrorPluginItem from './error-plugin-item' import PluginSection from './plugin-section' diff --git a/web/app/components/plugins/plugin-page/plugin-tasks/index.tsx b/web/app/components/plugins/plugin-page/plugin-tasks/index.tsx index 9a39b43bf6..7603cae33d 100644 --- a/web/app/components/plugins/plugin-page/plugin-tasks/index.tsx +++ b/web/app/components/plugins/plugin-page/plugin-tasks/index.tsx @@ -1,14 +1,14 @@ +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, +} from '@langgenius/dify-ui/dropdown-menu' import { useCallback, useMemo, useState, } from 'react' import { useTranslation } from 'react-i18next' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuTrigger, -} from '@/app/components/base/ui/dropdown-menu' import useGetIcon from '@/app/components/plugins/install-plugin/base/use-get-icon' import PluginTaskList from './components/plugin-task-list' import TaskStatusIndicator from './components/task-status-indicator' diff --git a/web/app/components/plugins/plugin-page/plugins-panel.tsx b/web/app/components/plugins/plugin-page/plugins-panel.tsx index 142c9329cc..a824d55dda 100644 --- a/web/app/components/plugins/plugin-page/plugins-panel.tsx +++ b/web/app/components/plugins/plugin-page/plugins-panel.tsx @@ -1,11 +1,11 @@ 'use client' import type { PluginDetail } from '../types' import type { FilterState } from './filter-management' +import { Button } from '@langgenius/dify-ui/button' import { useDebounceFn } from 'ahooks' import { useMemo } from 'react' import { useTranslation } from 'react-i18next' import Loading from '@/app/components/base/loading' -import { Button } from '@/app/components/base/ui/button' import PluginDetailPanel from '@/app/components/plugins/plugin-detail-panel' import { useGetLanguage } from '@/context/i18n' import { renderI18nObject } from '@/i18n-config' diff --git a/web/app/components/plugins/plugin-page/use-reference-setting.ts b/web/app/components/plugins/plugin-page/use-reference-setting.ts index 0a0e925207..b688bdc11e 100644 --- a/web/app/components/plugins/plugin-page/use-reference-setting.ts +++ b/web/app/components/plugins/plugin-page/use-reference-setting.ts @@ -1,6 +1,6 @@ +import { toast } from '@langgenius/dify-ui/toast' import { useMemo } from 'react' import { useTranslation } from 'react-i18next' -import { toast } from '@/app/components/base/ui/toast' import { useAppContext } from '@/context/app-context' import { useGlobalPublicStore } from '@/context/global-public-context' import { useInvalidateReferenceSettings, useMutationReferenceSettings, useReferenceSettings } from '@/service/use-plugins' diff --git a/web/app/components/plugins/provider-card.tsx b/web/app/components/plugins/provider-card.tsx index 7f07699771..06b611b355 100644 --- a/web/app/components/plugins/provider-card.tsx +++ b/web/app/components/plugins/provider-card.tsx @@ -1,6 +1,7 @@ 'use client' import type { FC } from 'react' import type { Plugin } from './types' +import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { RiArrowRightUpLine } from '@remixicon/react' import { useBoolean } from 'ahooks' @@ -8,7 +9,6 @@ import { useTheme } from 'next-themes' import * as React from 'react' import { useMemo } from 'react' import { useTranslation } from 'react-i18next' -import { Button } from '@/app/components/base/ui/button' import InstallFromMarketplace from '@/app/components/plugins/install-plugin/install-from-marketplace' import { getPluginLinkInMarketplace } from '@/app/components/plugins/marketplace/utils' import { useLocale } from '@/context/i18n' diff --git a/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/plugins-picker.spec.tsx b/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/plugins-picker.spec.tsx index 299eecce54..99d7a5fdc5 100644 --- a/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/plugins-picker.spec.tsx +++ b/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/plugins-picker.spec.tsx @@ -5,7 +5,7 @@ import { AUTO_UPDATE_MODE } from '../types' const mockToolPicker = vi.fn() -vi.mock('@/app/components/base/ui/button', () => ({ +vi.mock('@langgenius/dify-ui/button', () => ({ Button: ({ children, }: { diff --git a/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/strategy-picker.spec.tsx b/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/strategy-picker.spec.tsx index e1e72fbde2..5be10ff146 100644 --- a/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/strategy-picker.spec.tsx +++ b/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/strategy-picker.spec.tsx @@ -5,7 +5,7 @@ import { AUTO_UPDATE_STRATEGY } from '../types' let portalOpen = false -vi.mock('@/app/components/base/ui/button', () => ({ +vi.mock('@langgenius/dify-ui/button', () => ({ Button: ({ children, }: { diff --git a/web/app/components/plugins/reference-setting-modal/auto-update-setting/plugins-picker.tsx b/web/app/components/plugins/reference-setting-modal/auto-update-setting/plugins-picker.tsx index c0dbbea5c6..1bb4caeb3f 100644 --- a/web/app/components/plugins/reference-setting-modal/auto-update-setting/plugins-picker.tsx +++ b/web/app/components/plugins/reference-setting-modal/auto-update-setting/plugins-picker.tsx @@ -1,10 +1,10 @@ 'use client' import type { FC } from 'react' +import { Button } from '@langgenius/dify-ui/button' import { RiAddLine } from '@remixicon/react' import { useBoolean } from 'ahooks' import * as React from 'react' import { useTranslation } from 'react-i18next' -import { Button } from '@/app/components/base/ui/button' import NoPluginSelected from './no-plugin-selected' import PluginsSelected from './plugins-selected' import ToolPicker from './tool-picker' diff --git a/web/app/components/plugins/reference-setting-modal/auto-update-setting/strategy-picker.tsx b/web/app/components/plugins/reference-setting-modal/auto-update-setting/strategy-picker.tsx index 0f85b95e3c..22bfa6a30b 100644 --- a/web/app/components/plugins/reference-setting-modal/auto-update-setting/strategy-picker.tsx +++ b/web/app/components/plugins/reference-setting-modal/auto-update-setting/strategy-picker.tsx @@ -1,3 +1,4 @@ +import { Button } from '@langgenius/dify-ui/button' import { RiArrowDownSLine, RiCheckLine, @@ -9,7 +10,6 @@ import { PortalToFollowElemContent, PortalToFollowElemTrigger, } from '@/app/components/base/portal-to-follow-elem' -import { Button } from '@/app/components/base/ui/button' import { AUTO_UPDATE_STRATEGY } from './types' const i18nPrefix = 'autoUpdate.strategy' diff --git a/web/app/components/plugins/reference-setting-modal/index.tsx b/web/app/components/plugins/reference-setting-modal/index.tsx index bbf19d8c0a..061777e38b 100644 --- a/web/app/components/plugins/reference-setting-modal/index.tsx +++ b/web/app/components/plugins/reference-setting-modal/index.tsx @@ -2,11 +2,11 @@ import type { FC } from 'react' import type { AutoUpdateConfig } from './auto-update-setting/types' import type { Permissions, ReferenceSetting } from '@/app/components/plugins/types' +import { Button } from '@langgenius/dify-ui/button' import * as React from 'react' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import Modal from '@/app/components/base/modal' -import { Button } from '@/app/components/base/ui/button' import { PermissionType } from '@/app/components/plugins/types' import OptionCard from '@/app/components/workflow/nodes/_base/components/option-card' import { useGlobalPublicStore } from '@/context/global-public-context' diff --git a/web/app/components/plugins/update-plugin/__tests__/from-market-place.spec.tsx b/web/app/components/plugins/update-plugin/__tests__/from-market-place.spec.tsx index 9d9ad77106..3cc3ef78d1 100644 --- a/web/app/components/plugins/update-plugin/__tests__/from-market-place.spec.tsx +++ b/web/app/components/plugins/update-plugin/__tests__/from-market-place.spec.tsx @@ -22,7 +22,7 @@ const { mockToastError: vi.fn(), })) -vi.mock('@/app/components/base/ui/dialog', () => ({ +vi.mock('@langgenius/dify-ui/dialog', () => ({ Dialog: ({ children }: { children: React.ReactNode }) =>
{children}
, DialogContent: ({ children }: { children: React.ReactNode }) =>
{children}
, DialogTitle: ({ children }: { children: React.ReactNode }) =>
{children}
, @@ -37,7 +37,7 @@ vi.mock('@/app/components/base/badge/index', () => ({ default: ({ children }: { children: React.ReactNode }) =>
{children}
, })) -vi.mock('@/app/components/base/ui/button', () => ({ +vi.mock('@langgenius/dify-ui/button', () => ({ Button: ({ children, onClick, @@ -49,7 +49,7 @@ vi.mock('@/app/components/base/ui/button', () => ({ }) => , })) -vi.mock('@/app/components/base/ui/toast', () => ({ +vi.mock('@langgenius/dify-ui/toast', () => ({ toast: { error: mockToastError, }, diff --git a/web/app/components/plugins/update-plugin/__tests__/index.spec.tsx b/web/app/components/plugins/update-plugin/__tests__/index.spec.tsx index cdd7462342..d0492696ca 100644 --- a/web/app/components/plugins/update-plugin/__tests__/index.spec.tsx +++ b/web/app/components/plugins/update-plugin/__tests__/index.spec.tsx @@ -4,11 +4,11 @@ import type { UpdateFromMarketPlacePayload, UpdatePluginModalType, } from '../../types' +import { toast } from '@langgenius/dify-ui/toast' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import * as React from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { toast } from '@/app/components/base/ui/toast' import { PluginCategoryEnum, PluginSource, TaskStatus } from '../../types' import DowngradeWarningModal from '../downgrade-warning' import FromGitHub from '../from-github' diff --git a/web/app/components/plugins/update-plugin/downgrade-warning.tsx b/web/app/components/plugins/update-plugin/downgrade-warning.tsx index da53ebfb12..801c8ede51 100644 --- a/web/app/components/plugins/update-plugin/downgrade-warning.tsx +++ b/web/app/components/plugins/update-plugin/downgrade-warning.tsx @@ -1,5 +1,5 @@ +import { Button } from '@langgenius/dify-ui/button' import { useTranslation } from 'react-i18next' -import { Button } from '@/app/components/base/ui/button' const i18nPrefix = 'autoUpdate.pluginDowngradeWarning' diff --git a/web/app/components/plugins/update-plugin/from-market-place.tsx b/web/app/components/plugins/update-plugin/from-market-place.tsx index bde5051808..52484e65aa 100644 --- a/web/app/components/plugins/update-plugin/from-market-place.tsx +++ b/web/app/components/plugins/update-plugin/from-market-place.tsx @@ -1,19 +1,19 @@ 'use client' import type { FC } from 'react' import type { UpdateFromMarketPlacePayload } from '../types' +import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' -import * as React from 'react' -import { useCallback, useEffect, useMemo, useState } from 'react' -import { useTranslation } from 'react-i18next' -import Badge, { BadgeState } from '@/app/components/base/badge/index' -import { Button } from '@/app/components/base/ui/button' import { Dialog, DialogCloseButton, DialogContent, DialogTitle, -} from '@/app/components/base/ui/dialog' -import { toast } from '@/app/components/base/ui/toast' +} from '@langgenius/dify-ui/dialog' +import { toast } from '@langgenius/dify-ui/toast' +import * as React from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import Badge, { BadgeState } from '@/app/components/base/badge/index' import Card from '@/app/components/plugins/card' import checkTaskStatus from '@/app/components/plugins/install-plugin/base/check-task-status' import { pluginManifestToCardPluginProps } from '@/app/components/plugins/install-plugin/utils' diff --git a/web/app/components/plugins/update-plugin/plugin-version-picker.tsx b/web/app/components/plugins/update-plugin/plugin-version-picker.tsx index d590f0c9eb..a76fd085ba 100644 --- a/web/app/components/plugins/update-plugin/plugin-version-picker.tsx +++ b/web/app/components/plugins/update-plugin/plugin-version-picker.tsx @@ -1,16 +1,16 @@ 'use client' +import type { Placement } from '@langgenius/dify-ui/popover' import type { FC } from 'react' -import type { Placement } from '@/app/components/base/ui/placement' import { cn } from '@langgenius/dify-ui/cn' -import * as React from 'react' -import { useCallback } from 'react' -import { useTranslation } from 'react-i18next' -import Badge from '@/app/components/base/badge' import { Popover, PopoverContent, PopoverTrigger, -} from '@/app/components/base/ui/popover' +} from '@langgenius/dify-ui/popover' +import * as React from 'react' +import { useCallback } from 'react' +import { useTranslation } from 'react-i18next' +import Badge from '@/app/components/base/badge' import useTimestamp from '@/hooks/use-timestamp' import { useVersionListOfPlugin } from '@/service/use-plugins' import { isEarlierThanVersion } from '@/utils/semver' diff --git a/web/app/components/rag-pipeline/components/__tests__/conversion.spec.tsx b/web/app/components/rag-pipeline/components/__tests__/conversion.spec.tsx index 967e697813..cb11e5baf4 100644 --- a/web/app/components/rag-pipeline/components/__tests__/conversion.spec.tsx +++ b/web/app/components/rag-pipeline/components/__tests__/conversion.spec.tsx @@ -37,11 +37,11 @@ const { mockToast } = vi.hoisted(() => { return { mockToast } }) -vi.mock('@/app/components/base/ui/toast', () => ({ +vi.mock('@langgenius/dify-ui/toast', () => ({ toast: mockToast, })) -vi.mock('@/app/components/base/ui/button', () => ({ +vi.mock('@langgenius/dify-ui/button', () => ({ Button: ({ children, onClick, ...props }: Record) => ( ), diff --git a/web/app/components/rag-pipeline/components/__tests__/publish-as-knowledge-pipeline-modal.spec.tsx b/web/app/components/rag-pipeline/components/__tests__/publish-as-knowledge-pipeline-modal.spec.tsx index f9eb858b30..7a99b7ab90 100644 --- a/web/app/components/rag-pipeline/components/__tests__/publish-as-knowledge-pipeline-modal.spec.tsx +++ b/web/app/components/rag-pipeline/components/__tests__/publish-as-knowledge-pipeline-modal.spec.tsx @@ -22,7 +22,7 @@ vi.mock('@/app/components/base/modal', () => ({ isShow ?
{children}
: null, })) -vi.mock('@/app/components/base/ui/button', () => ({ +vi.mock('@langgenius/dify-ui/button', () => ({ Button: ({ children, onClick, disabled, ...props }: Record) => ( @@ -83,7 +83,7 @@ vi.mock('@/app/components/base/ui/dropdown-menu', () => ({ DropdownMenuContent: ({ children }: { children: React.ReactNode }) =>
{children}
, })) -vi.mock('@/app/components/base/ui/tooltip', () => ({ +vi.mock('@langgenius/dify-ui/tooltip', () => ({ Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}, TooltipTrigger: ({ children, diff --git a/web/app/components/workflow/comment/thread.tsx b/web/app/components/workflow/comment/thread.tsx index d0ab65db15..34e0092372 100644 --- a/web/app/components/workflow/comment/thread.tsx +++ b/web/app/components/workflow/comment/thread.tsx @@ -2,20 +2,20 @@ import type { FC, ReactNode } from 'react' import type { WorkflowCommentDetail, WorkflowCommentDetailReply } from '@/service/workflow-comment' +import { Avatar, AvatarFallback, AvatarImage, AvatarRoot } from '@langgenius/dify-ui/avatar' import { cn } from '@langgenius/dify-ui/cn' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, +} from '@langgenius/dify-ui/dropdown-menu' +import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { RiArrowDownSLine, RiArrowUpSLine, RiCheckboxCircleFill, RiCheckboxCircleLine, RiCloseLine, RiDeleteBinLine, RiMoreFill } from '@remixicon/react' import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { useReactFlow, useViewport } from 'reactflow' import Divider from '@/app/components/base/divider' import InlineDeleteConfirm from '@/app/components/base/inline-delete-confirm' -import { Avatar, AvatarFallback, AvatarImage, AvatarRoot } from '@/app/components/base/ui/avatar' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuTrigger, -} from '@/app/components/base/ui/dropdown-menu' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/app/components/base/ui/tooltip' import { getUserColor } from '@/app/components/workflow/collaboration/utils/user-color' import { useAppContext } from '@/context/app-context' import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now' diff --git a/web/app/components/workflow/dsl-export-confirm-modal.tsx b/web/app/components/workflow/dsl-export-confirm-modal.tsx index b256f8bd9f..6f60e19854 100644 --- a/web/app/components/workflow/dsl-export-confirm-modal.tsx +++ b/web/app/components/workflow/dsl-export-confirm-modal.tsx @@ -1,5 +1,6 @@ 'use client' import type { EnvironmentVariable } from '@/app/components/workflow/types' +import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { RiCloseLine, RiLock2Line } from '@remixicon/react' import { noop } from 'es-toolkit/function' @@ -9,7 +10,6 @@ import { useTranslation } from 'react-i18next' import Checkbox from '@/app/components/base/checkbox' import { Env } from '@/app/components/base/icons/src/vender/line/others' import Modal from '@/app/components/base/modal' -import { Button } from '@/app/components/base/ui/button' export type DSLExportConfirmModalProps = { envList: EnvironmentVariable[] diff --git a/web/app/components/workflow/edge-contextmenu.tsx b/web/app/components/workflow/edge-contextmenu.tsx index 61b208fcbd..2b7f13190a 100644 --- a/web/app/components/workflow/edge-contextmenu.tsx +++ b/web/app/components/workflow/edge-contextmenu.tsx @@ -1,14 +1,14 @@ +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, +} from '@langgenius/dify-ui/context-menu' import { memo, useMemo, } from 'react' import { useTranslation } from 'react-i18next' import { useEdges } from 'reactflow' -import { - ContextMenu, - ContextMenuContent, - ContextMenuItem, -} from '@/app/components/base/ui/context-menu' import { useEdgesInteractions, usePanelInteractions } from './hooks' import ShortcutsName from './shortcuts-name' import { useStore } from './store' diff --git a/web/app/components/workflow/header/__tests__/header-layouts.spec.tsx b/web/app/components/workflow/header/__tests__/header-layouts.spec.tsx index f04a160967..1ddf013f8d 100644 --- a/web/app/components/workflow/header/__tests__/header-layouts.spec.tsx +++ b/web/app/components/workflow/header/__tests__/header-layouts.spec.tsx @@ -60,7 +60,7 @@ vi.mock('@/service/use-workflow', () => ({ useInvalidAllLastRun: () => mockInvalidAllLastRun, })) -vi.mock('@/app/components/base/ui/toast', () => ({ +vi.mock('@langgenius/dify-ui/toast', () => ({ toast: { success: (message: string) => mockNotify({ type: 'success', message }), error: (message: string) => mockNotify({ type: 'error', message }), diff --git a/web/app/components/workflow/header/__tests__/run-mode.spec.tsx b/web/app/components/workflow/header/__tests__/run-mode.spec.tsx index 7bc5ef1b0c..02b645e079 100644 --- a/web/app/components/workflow/header/__tests__/run-mode.spec.tsx +++ b/web/app/components/workflow/header/__tests__/run-mode.spec.tsx @@ -46,7 +46,7 @@ vi.mock('../../hooks/use-dynamic-test-run-options', () => ({ useDynamicTestRunOptions: () => mockDynamicOptions, })) -vi.mock('@/app/components/base/ui/toast', () => ({ +vi.mock('@langgenius/dify-ui/toast', () => ({ toast: { success: (message: string) => mockNotify({ type: 'success', message }), error: (message: string) => mockNotify({ type: 'error', message }), diff --git a/web/app/components/workflow/header/__tests__/test-run-menu-helpers.spec.tsx b/web/app/components/workflow/header/__tests__/test-run-menu-helpers.spec.tsx index 7df9cd091f..09452055db 100644 --- a/web/app/components/workflow/header/__tests__/test-run-menu-helpers.spec.tsx +++ b/web/app/components/workflow/header/__tests__/test-run-menu-helpers.spec.tsx @@ -10,7 +10,7 @@ import { useShortcutMenu, } from '../test-run-menu-helpers' -vi.mock('@/app/components/base/ui/dropdown-menu', async () => { +vi.mock('@langgenius/dify-ui/dropdown-menu', async () => { const React = await import('react') const DropdownMenuContext = React.createContext<{ open: boolean, setOpen: (open: boolean) => void } | null>(null) diff --git a/web/app/components/workflow/header/__tests__/test-run-menu.spec.tsx b/web/app/components/workflow/header/__tests__/test-run-menu.spec.tsx index 1d462bfc9c..8e93612dc0 100644 --- a/web/app/components/workflow/header/__tests__/test-run-menu.spec.tsx +++ b/web/app/components/workflow/header/__tests__/test-run-menu.spec.tsx @@ -5,7 +5,7 @@ import { act } from 'react' import * as React from 'react' import TestRunMenu, { TriggerType } from '../test-run-menu' -vi.mock('@/app/components/base/ui/dropdown-menu', async () => { +vi.mock('@langgenius/dify-ui/dropdown-menu', async () => { const React = await import('react') const DropdownMenuContext = React.createContext<{ open: boolean, setOpen: (open: boolean) => void } | null>(null) diff --git a/web/app/components/workflow/header/chat-variable-button.tsx b/web/app/components/workflow/header/chat-variable-button.tsx index 63ac90aad6..efac64bd1b 100644 --- a/web/app/components/workflow/header/chat-variable-button.tsx +++ b/web/app/components/workflow/header/chat-variable-button.tsx @@ -1,7 +1,7 @@ +import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { memo } from 'react' import { BubbleX } from '@/app/components/base/icons/src/vender/line/others' -import { Button } from '@/app/components/base/ui/button' import { useStore } from '@/app/components/workflow/store' import useTheme from '@/hooks/use-theme' diff --git a/web/app/components/workflow/header/checklist/__tests__/index.spec.tsx b/web/app/components/workflow/header/checklist/__tests__/index.spec.tsx index 2c83747dc0..25150e2d04 100644 --- a/web/app/components/workflow/header/checklist/__tests__/index.spec.tsx +++ b/web/app/components/workflow/header/checklist/__tests__/index.spec.tsx @@ -47,7 +47,7 @@ vi.mock('../../../hooks', () => ({ }), })) -vi.mock('@/app/components/base/ui/popover', () => ({ +vi.mock('@langgenius/dify-ui/popover', () => ({ Popover: ({ children, onOpenChange }: PopoverProps) => { latestOnOpenChange = onOpenChange return
{children}
diff --git a/web/app/components/workflow/header/checklist/__tests__/plugin-group.spec.tsx b/web/app/components/workflow/header/checklist/__tests__/plugin-group.spec.tsx index 275c8727d1..78341c2174 100644 --- a/web/app/components/workflow/header/checklist/__tests__/plugin-group.spec.tsx +++ b/web/app/components/workflow/header/checklist/__tests__/plugin-group.spec.tsx @@ -1,7 +1,7 @@ import type { ChecklistItem } from '../../../hooks/use-checklist' +import { Popover, PopoverContent } from '@langgenius/dify-ui/popover' import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it } from 'vitest' -import { Popover, PopoverContent } from '@/app/components/base/ui/popover' import { useStore as usePluginDependencyStore } from '../../../plugin-dependency/store' import { BlockEnum } from '../../../types' import { ChecklistPluginGroup } from '../plugin-group' diff --git a/web/app/components/workflow/header/checklist/index.tsx b/web/app/components/workflow/header/checklist/index.tsx index 83384c5583..9a54175c8c 100644 --- a/web/app/components/workflow/header/checklist/index.tsx +++ b/web/app/components/workflow/header/checklist/index.tsx @@ -3,6 +3,12 @@ import type { CommonEdgeType, } from '../../types' import { cn } from '@langgenius/dify-ui/cn' +import { + Popover, + PopoverClose, + PopoverContent, + PopoverTrigger, +} from '@langgenius/dify-ui/popover' import { memo, useMemo, @@ -12,12 +18,6 @@ import { useTranslation } from 'react-i18next' import { useEdges, } from 'reactflow' -import { - Popover, - PopoverClose, - PopoverContent, - PopoverTrigger, -} from '@/app/components/base/ui/popover' import useNodes from '@/app/components/workflow/store/workflow/use-nodes' import { useChecklist, diff --git a/web/app/components/workflow/header/checklist/plugin-group.tsx b/web/app/components/workflow/header/checklist/plugin-group.tsx index f74b314a10..91bc385595 100644 --- a/web/app/components/workflow/header/checklist/plugin-group.tsx +++ b/web/app/components/workflow/header/checklist/plugin-group.tsx @@ -1,10 +1,10 @@ import type { ChecklistItem } from '../../hooks/use-checklist' import type { BlockEnum } from '../../types' import type { Dependency } from '@/app/components/plugins/types' +import { Button } from '@langgenius/dify-ui/button' +import { PopoverClose } from '@langgenius/dify-ui/popover' import { memo, useMemo } from 'react' import { useTranslation } from 'react-i18next' -import { Button } from '@/app/components/base/ui/button' -import { PopoverClose } from '@/app/components/base/ui/popover' import BlockIcon from '../../block-icon' import { useStore as usePluginDependencyStore } from '../../plugin-dependency/store' import { ItemIndicator } from './item-indicator' diff --git a/web/app/components/workflow/header/env-button.tsx b/web/app/components/workflow/header/env-button.tsx index 98ab6ba537..eec4af84c8 100644 --- a/web/app/components/workflow/header/env-button.tsx +++ b/web/app/components/workflow/header/env-button.tsx @@ -1,7 +1,7 @@ +import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { memo } from 'react' import { Env } from '@/app/components/base/icons/src/vender/line/others' -import { Button } from '@/app/components/base/ui/button' import { useInputFieldPanel } from '@/app/components/rag-pipeline/hooks' import { useStore } from '@/app/components/workflow/store' import useTheme from '@/hooks/use-theme' diff --git a/web/app/components/workflow/header/global-variable-button.tsx b/web/app/components/workflow/header/global-variable-button.tsx index 63a4a427b6..f42677a3df 100644 --- a/web/app/components/workflow/header/global-variable-button.tsx +++ b/web/app/components/workflow/header/global-variable-button.tsx @@ -1,7 +1,7 @@ +import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { memo } from 'react' import { GlobalVariable } from '@/app/components/base/icons/src/vender/line/others' -import { Button } from '@/app/components/base/ui/button' import { useInputFieldPanel } from '@/app/components/rag-pipeline/hooks' import { useStore } from '@/app/components/workflow/store' import useTheme from '@/hooks/use-theme' diff --git a/web/app/components/workflow/header/header-in-restoring.tsx b/web/app/components/workflow/header/header-in-restoring.tsx index e6ad4123ed..f07d28ff13 100644 --- a/web/app/components/workflow/header/header-in-restoring.tsx +++ b/web/app/components/workflow/header/header-in-restoring.tsx @@ -1,12 +1,12 @@ +import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' +import { toast } from '@langgenius/dify-ui/toast' import { RiHistoryLine } from '@remixicon/react' import { useCallback, } from 'react' import { useTranslation } from 'react-i18next' import { useFeaturesStore } from '@/app/components/base/features/hooks' -import { Button } from '@/app/components/base/ui/button' -import { toast } from '@/app/components/base/ui/toast' import { useSelector as useAppContextSelector } from '@/context/app-context' import useTheme from '@/hooks/use-theme' import { useInvalidAllLastRun } from '@/service/use-workflow' diff --git a/web/app/components/workflow/header/header-in-view-history.tsx b/web/app/components/workflow/header/header-in-view-history.tsx index cd39ce605a..88077002c7 100644 --- a/web/app/components/workflow/header/header-in-view-history.tsx +++ b/web/app/components/workflow/header/header-in-view-history.tsx @@ -1,10 +1,10 @@ import type { ViewHistoryProps } from './view-history' +import { Button } from '@langgenius/dify-ui/button' import { useCallback, } from 'react' import { useTranslation } from 'react-i18next' import { ArrowNarrowLeft } from '@/app/components/base/icons/src/vender/line/arrows' -import { Button } from '@/app/components/base/ui/button' import Divider from '../../base/divider' import { useWorkflowRun, diff --git a/web/app/components/workflow/header/online-users.tsx b/web/app/components/workflow/header/online-users.tsx index cce2af0cdc..0f057f0787 100644 --- a/web/app/components/workflow/header/online-users.tsx +++ b/web/app/components/workflow/header/online-users.tsx @@ -1,17 +1,17 @@ 'use client' import type { OnlineUser } from '../collaboration/types/collaboration' import { ChevronDownIcon } from '@heroicons/react/20/solid' +import { AvatarFallback, AvatarImage, AvatarRoot } from '@langgenius/dify-ui/avatar' import { cn } from '@langgenius/dify-ui/cn' -import { useEffect, useState } from 'react' -import { useTranslation } from 'react-i18next' -import { useReactFlow } from 'reactflow' -import { AvatarFallback, AvatarImage, AvatarRoot } from '@/app/components/base/ui/avatar' import { Popover, PopoverContent, PopoverTrigger, -} from '@/app/components/base/ui/popover' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/app/components/base/ui/tooltip' +} from '@langgenius/dify-ui/popover' +import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' +import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useReactFlow } from 'reactflow' import { useAppContext } from '@/context/app-context' import { getAvatar } from '@/service/common' import { useCollaboration } from '../collaboration/hooks/use-collaboration' diff --git a/web/app/components/workflow/header/run-mode.tsx b/web/app/components/workflow/header/run-mode.tsx index d98b4d6b18..923f6f0330 100644 --- a/web/app/components/workflow/header/run-mode.tsx +++ b/web/app/components/workflow/header/run-mode.tsx @@ -1,12 +1,12 @@ import type { TestRunMenuRef, TriggerOption } from './test-run-menu' import { cn } from '@langgenius/dify-ui/cn' +import { toast } from '@langgenius/dify-ui/toast' import { RiLoader2Line, RiPlayLargeLine } from '@remixicon/react' import * as React from 'react' import { useCallback, useEffect, useRef } from 'react' import { useTranslation } from 'react-i18next' import { trackEvent } from '@/app/components/base/amplitude' import { StopCircle } from '@/app/components/base/icons/src/vender/line/mediaAndDevices' -import { toast } from '@/app/components/base/ui/toast' import { useWorkflowRun, useWorkflowRunValidation, useWorkflowStartRun } from '@/app/components/workflow/hooks' import ShortcutsName from '@/app/components/workflow/shortcuts-name' import { useStore } from '@/app/components/workflow/store' diff --git a/web/app/components/workflow/header/test-run-menu-helpers.tsx b/web/app/components/workflow/header/test-run-menu-helpers.tsx index 4a25cd87a6..9f14190c54 100644 --- a/web/app/components/workflow/header/test-run-menu-helpers.tsx +++ b/web/app/components/workflow/header/test-run-menu-helpers.tsx @@ -1,12 +1,12 @@ /* eslint-disable react-refresh/only-export-components */ import type { MouseEvent, MouseEventHandler, ReactElement } from 'react' import type { TriggerOption } from './test-run-menu' +import { DropdownMenuItem } from '@langgenius/dify-ui/dropdown-menu' import { cloneElement, isValidElement, useEffect, } from 'react' -import { DropdownMenuItem } from '@/app/components/base/ui/dropdown-menu' import ShortcutsName from '../shortcuts-name' export type ShortcutMapping = { diff --git a/web/app/components/workflow/header/test-run-menu.tsx b/web/app/components/workflow/header/test-run-menu.tsx index 6540875e6b..ceaf38592f 100644 --- a/web/app/components/workflow/header/test-run-menu.tsx +++ b/web/app/components/workflow/header/test-run-menu.tsx @@ -1,7 +1,7 @@ import type { ShortcutMapping } from './test-run-menu-helpers' +import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from '@langgenius/dify-ui/dropdown-menu' import { forwardRef, useCallback, useImperativeHandle, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' -import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from '@/app/components/base/ui/dropdown-menu' import { OptionRow, SingleOptionTrigger, useShortcutMenu } from './test-run-menu-helpers' export enum TriggerType { diff --git a/web/app/components/workflow/header/version-history-button.tsx b/web/app/components/workflow/header/version-history-button.tsx index ef79c4e411..69dc2b4c4c 100644 --- a/web/app/components/workflow/header/version-history-button.tsx +++ b/web/app/components/workflow/header/version-history-button.tsx @@ -1,11 +1,11 @@ import type { FC } from 'react' +import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { RiHistoryLine } from '@remixicon/react' import { useKeyPress } from 'ahooks' import * as React from 'react' import { useCallback } from 'react' import { useTranslation } from 'react-i18next' -import { Button } from '@/app/components/base/ui/button' import useTheme from '@/hooks/use-theme' import Tooltip from '../../base/tooltip' import ShortcutsName from '../shortcuts-name' diff --git a/web/app/components/workflow/hooks/__tests__/use-checklist.spec.ts b/web/app/components/workflow/hooks/__tests__/use-checklist.spec.ts index 891007ff0e..f6d2774611 100644 --- a/web/app/components/workflow/hooks/__tests__/use-checklist.spec.ts +++ b/web/app/components/workflow/hooks/__tests__/use-checklist.spec.ts @@ -89,7 +89,7 @@ vi.mock('../index', () => ({ useNodesMetaData: () => ({ nodes: [], nodesMap: mockNodesMap }), })) -vi.mock('@/app/components/base/ui/toast', () => ({ +vi.mock('@langgenius/dify-ui/toast', () => ({ toast: { success: vi.fn(), error: vi.fn(), diff --git a/web/app/components/workflow/hooks/__tests__/use-leader-restore.spec.ts b/web/app/components/workflow/hooks/__tests__/use-leader-restore.spec.ts index 5837f1484d..a050994f4c 100644 --- a/web/app/components/workflow/hooks/__tests__/use-leader-restore.spec.ts +++ b/web/app/components/workflow/hooks/__tests__/use-leader-restore.spec.ts @@ -66,7 +66,7 @@ vi.mock('@/app/components/base/features/hooks', () => ({ }), })) -vi.mock('@/app/components/base/ui/toast', () => ({ +vi.mock('@langgenius/dify-ui/toast', () => ({ toast: { info: (...args: unknown[]) => mockToastInfo(...args), }, diff --git a/web/app/components/workflow/hooks/use-checklist.ts b/web/app/components/workflow/hooks/use-checklist.ts index abafcfd8eb..ae9cc0a4a2 100644 --- a/web/app/components/workflow/hooks/use-checklist.ts +++ b/web/app/components/workflow/hooks/use-checklist.ts @@ -16,6 +16,7 @@ import type { ModelItem } from '@/app/components/header/account-setting/model-pr import type { Emoji } from '@/app/components/tools/types' import type { DataSet } from '@/models/datasets' import type { I18nKeysWithPrefix } from '@/types/i18n' +import { toast } from '@langgenius/dify-ui/toast' import { useQueries, useQueryClient } from '@tanstack/react-query' import isDeepEqual from 'fast-deep-equal' import { @@ -27,7 +28,6 @@ import { import { useTranslation } from 'react-i18next' import { useEdges, useStoreApi } from 'reactflow' import { useStore as useAppStore } from '@/app/components/app/store' -import { toast } from '@/app/components/base/ui/toast' import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' import { useModelList } from '@/app/components/header/account-setting/model-provider-page/hooks' import useNodes from '@/app/components/workflow/store/workflow/use-nodes' diff --git a/web/app/components/workflow/hooks/use-leader-restore.ts b/web/app/components/workflow/hooks/use-leader-restore.ts index 048bb932e0..da0036a4ea 100644 --- a/web/app/components/workflow/hooks/use-leader-restore.ts +++ b/web/app/components/workflow/hooks/use-leader-restore.ts @@ -1,11 +1,11 @@ import type { RestoreCompleteData, RestoreIntentData, RestoreRequestData } from '../collaboration/types/collaboration' import type { SyncCallback } from './use-nodes-sync-draft' +import { toast } from '@langgenius/dify-ui/toast' import { useCallback, useEffect, useRef } from 'react' import { useTranslation } from 'react-i18next' import { useReactFlow } from 'reactflow' import { useStore as useAppStore } from '@/app/components/app/store' import { useFeaturesStore } from '@/app/components/base/features/hooks' -import { toast } from '@/app/components/base/ui/toast' import { useGlobalPublicStore } from '@/context/global-public-context' import { collaborationManager } from '../collaboration/core/collaboration-manager' import { useWorkflowStore } from '../store' diff --git a/web/app/components/workflow/index.tsx b/web/app/components/workflow/index.tsx index 12122c5b72..c535196691 100644 --- a/web/app/components/workflow/index.tsx +++ b/web/app/components/workflow/index.tsx @@ -14,7 +14,17 @@ import type { Node, } from './types' import type { VarInInspect } from '@/types/workflow' +import { + AlertDialog, + AlertDialogActions, + AlertDialogCancelButton, + AlertDialogConfirmButton, + AlertDialogContent, + AlertDialogDescription, + AlertDialogTitle, +} from '@langgenius/dify-ui/alert-dialog' import { cn } from '@langgenius/dify-ui/cn' +import { toast } from '@langgenius/dify-ui/toast' import { useEventListener, } from 'ahooks' @@ -41,16 +51,6 @@ import ReactFlow, { useReactFlow, useStoreApi, } from 'reactflow' -import { - AlertDialog, - AlertDialogActions, - AlertDialogCancelButton, - AlertDialogConfirmButton, - AlertDialogContent, - AlertDialogDescription, - AlertDialogTitle, -} from '@/app/components/base/ui/alert-dialog' -import { toast } from '@/app/components/base/ui/toast' import { IS_DEV } from '@/config' import { useEventEmitterContextContext } from '@/context/event-emitter' import { diff --git a/web/app/components/workflow/nodes/_base/components/__tests__/file-support.spec.tsx b/web/app/components/workflow/nodes/_base/components/__tests__/file-support.spec.tsx index b58b045f92..1a3e004c7e 100644 --- a/web/app/components/workflow/nodes/_base/components/__tests__/file-support.spec.tsx +++ b/web/app/components/workflow/nodes/_base/components/__tests__/file-support.spec.tsx @@ -19,7 +19,7 @@ vi.mock('@/app/components/base/file-uploader/hooks', () => ({ useFileSizeLimit: vi.fn(), })) -vi.mock('@/app/components/base/ui/toast', () => ({ +vi.mock('@langgenius/dify-ui/toast', () => ({ toast: { success: vi.fn(), error: vi.fn(), diff --git a/web/app/components/workflow/nodes/_base/components/add-button.tsx b/web/app/components/workflow/nodes/_base/components/add-button.tsx index cd32ee0dc6..d64ab5d098 100644 --- a/web/app/components/workflow/nodes/_base/components/add-button.tsx +++ b/web/app/components/workflow/nodes/_base/components/add-button.tsx @@ -1,11 +1,11 @@ 'use client' import type { FC } from 'react' +import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { RiAddLine, } from '@remixicon/react' import * as React from 'react' -import { Button } from '@/app/components/base/ui/button' type Props = { className?: string diff --git a/web/app/components/workflow/nodes/_base/components/agent-strategy.tsx b/web/app/components/workflow/nodes/_base/components/agent-strategy.tsx index d13488a0b9..d20acd7f19 100644 --- a/web/app/components/workflow/nodes/_base/components/agent-strategy.tsx +++ b/web/app/components/workflow/nodes/_base/components/agent-strategy.tsx @@ -4,11 +4,6 @@ import type { NodeOutPutVar } from '../../../types' import type { ToolVarInputs } from '../../tool/types' import type { CredentialFormSchema, CredentialFormSchemaNumberInput, CredentialFormSchemaTextInput } from '@/app/components/header/account-setting/model-provider-page/declarations' import type { PluginMeta } from '@/app/components/plugins/types' -import { noop } from 'es-toolkit/function' -import { memo } from 'react' -import { useTranslation } from 'react-i18next' -import { Agent } from '@/app/components/base/icons/src/vender/workflow' -import ListEmpty from '@/app/components/base/list-empty' import { NumberField, NumberFieldControls, @@ -16,8 +11,13 @@ import { NumberFieldGroup, NumberFieldIncrement, NumberFieldInput, -} from '@/app/components/base/ui/number-field' -import { Slider } from '@/app/components/base/ui/slider' +} from '@langgenius/dify-ui/number-field' +import { Slider } from '@langgenius/dify-ui/slider' +import { noop } from 'es-toolkit/function' +import { memo } from 'react' +import { useTranslation } from 'react-i18next' +import { Agent } from '@/app/components/base/icons/src/vender/workflow' +import ListEmpty from '@/app/components/base/list-empty' import { FormTypeEnum, ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' import { useDefaultModel } from '@/app/components/header/account-setting/model-provider-page/hooks' import Form from '@/app/components/header/account-setting/model-provider-page/model-modal/Form' diff --git a/web/app/components/workflow/nodes/_base/components/before-run-form/__tests__/index.spec.tsx b/web/app/components/workflow/nodes/_base/components/before-run-form/__tests__/index.spec.tsx index a8837f6392..39b9fd8888 100644 --- a/web/app/components/workflow/nodes/_base/components/before-run-form/__tests__/index.spec.tsx +++ b/web/app/components/workflow/nodes/_base/components/before-run-form/__tests__/index.spec.tsx @@ -1,11 +1,11 @@ import type { Props as FormProps } from '../form' import type { BeforeRunFormProps } from '../index' +import { toast } from '@langgenius/dify-ui/toast' import { fireEvent, render, screen } from '@testing-library/react' -import { toast } from '@/app/components/base/ui/toast' import { BlockEnum, InputVarType } from '@/app/components/workflow/types' import BeforeRunForm from '../index' -vi.mock('@/app/components/base/ui/toast', () => ({ +vi.mock('@langgenius/dify-ui/toast', () => ({ toast: { error: vi.fn(), }, diff --git a/web/app/components/workflow/nodes/_base/components/before-run-form/index.tsx b/web/app/components/workflow/nodes/_base/components/before-run-form/index.tsx index f42046a2ad..f15c60e117 100644 --- a/web/app/components/workflow/nodes/_base/components/before-run-form/index.tsx +++ b/web/app/components/workflow/nodes/_base/components/before-run-form/index.tsx @@ -5,12 +5,12 @@ import type { Emoji } from '@/app/components/tools/types' import type { SpecialResultPanelProps } from '@/app/components/workflow/run/special-result-panel' import type { NodeRunningStatus } from '@/app/components/workflow/types' import type { HumanInputFormData } from '@/types/workflow' +import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' +import { toast } from '@langgenius/dify-ui/toast' import * as React from 'react' import { useEffect, useRef } from 'react' import { useTranslation } from 'react-i18next' -import { Button } from '@/app/components/base/ui/button' -import { toast } from '@/app/components/base/ui/toast' import Split from '@/app/components/workflow/nodes/_base/components/split' import SingleRunForm from '@/app/components/workflow/nodes/human-input/components/single-run-form' import { BlockEnum } from '@/app/components/workflow/types' diff --git a/web/app/components/workflow/nodes/_base/components/config-vision.tsx b/web/app/components/workflow/nodes/_base/components/config-vision.tsx index 109650cf51..d21e53368d 100644 --- a/web/app/components/workflow/nodes/_base/components/config-vision.tsx +++ b/web/app/components/workflow/nodes/_base/components/config-vision.tsx @@ -1,11 +1,11 @@ 'use client' import type { FC } from 'react' import type { ValueSelector, Var, VisionSetting } from '@/app/components/workflow/types' +import { Switch } from '@langgenius/dify-ui/switch' import { produce } from 'immer' import * as React from 'react' import { useCallback } from 'react' import { useTranslation } from 'react-i18next' -import Switch from '@/app/components/base/switch' import Tooltip from '@/app/components/base/tooltip' import Field from '@/app/components/workflow/nodes/_base/components/field' import ResolutionPicker from '@/app/components/workflow/nodes/llm/components/resolution-picker' diff --git a/web/app/components/workflow/nodes/_base/components/error-handle/error-handle-type-selector.tsx b/web/app/components/workflow/nodes/_base/components/error-handle/error-handle-type-selector.tsx index 92ca3ed335..07b6519e41 100644 --- a/web/app/components/workflow/nodes/_base/components/error-handle/error-handle-type-selector.tsx +++ b/web/app/components/workflow/nodes/_base/components/error-handle/error-handle-type-selector.tsx @@ -1,3 +1,4 @@ +import { Button } from '@langgenius/dify-ui/button' import { RiArrowDownSLine, RiCheckLine, @@ -9,7 +10,6 @@ import { PortalToFollowElemContent, PortalToFollowElemTrigger, } from '@/app/components/base/portal-to-follow-elem' -import { Button } from '@/app/components/base/ui/button' import { ErrorHandleTypeEnum } from './types' type ErrorHandleTypeSelectorProps = { diff --git a/web/app/components/workflow/nodes/_base/components/input-number-with-slider.tsx b/web/app/components/workflow/nodes/_base/components/input-number-with-slider.tsx index 4e253060d6..08afb50b80 100644 --- a/web/app/components/workflow/nodes/_base/components/input-number-with-slider.tsx +++ b/web/app/components/workflow/nodes/_base/components/input-number-with-slider.tsx @@ -1,8 +1,8 @@ 'use client' import type { FC } from 'react' +import { Slider } from '@langgenius/dify-ui/slider' import * as React from 'react' import { useCallback } from 'react' -import { Slider } from '@/app/components/base/ui/slider' export type InputNumberWithSliderProps = { value: number diff --git a/web/app/components/workflow/nodes/_base/components/install-plugin-button.tsx b/web/app/components/workflow/nodes/_base/components/install-plugin-button.tsx index f6e5434a2d..dd2611b395 100644 --- a/web/app/components/workflow/nodes/_base/components/install-plugin-button.tsx +++ b/web/app/components/workflow/nodes/_base/components/install-plugin-button.tsx @@ -1,9 +1,9 @@ import type { ComponentProps, MouseEventHandler } from 'react' +import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { RiInstallLine, RiLoader2Line } from '@remixicon/react' import { useState } from 'react' import { useTranslation } from 'react-i18next' -import { Button } from '@/app/components/base/ui/button' import checkTaskStatus from '@/app/components/plugins/install-plugin/base/check-task-status' import { TaskStatus } from '@/app/components/plugins/types' import { useCheckInstalled, useInstallPackageFromMarketPlace } from '@/service/use-plugins' diff --git a/web/app/components/workflow/nodes/_base/components/layout/__tests__/field-title.spec.tsx b/web/app/components/workflow/nodes/_base/components/layout/__tests__/field-title.spec.tsx index abd630cccb..c11b5ecfc8 100644 --- a/web/app/components/workflow/nodes/_base/components/layout/__tests__/field-title.spec.tsx +++ b/web/app/components/workflow/nodes/_base/components/layout/__tests__/field-title.spec.tsx @@ -1,7 +1,7 @@ import { fireEvent, render, screen } from '@testing-library/react' import { FieldTitle } from '../field-title' -vi.mock('@/app/components/base/ui/tooltip', () => ({ +vi.mock('@langgenius/dify-ui/tooltip', () => ({ Tooltip: ({ children }: { children: React.ReactNode }) =>
{children}
, TooltipTrigger: ({ render }: { render: React.ReactNode }) => <>{render}, TooltipContent: ({ children }: { children: React.ReactNode }) =>
{children}
, diff --git a/web/app/components/workflow/nodes/_base/components/layout/field-title.tsx b/web/app/components/workflow/nodes/_base/components/layout/field-title.tsx index 5a478c25d3..205e75bf67 100644 --- a/web/app/components/workflow/nodes/_base/components/layout/field-title.tsx +++ b/web/app/components/workflow/nodes/_base/components/layout/field-title.tsx @@ -1,10 +1,10 @@ import type { ReactNode } from 'react' import { cn } from '@langgenius/dify-ui/cn' +import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { memo, useState, } from 'react' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/app/components/base/ui/tooltip' export type FieldTitleProps = { title?: string diff --git a/web/app/components/workflow/nodes/_base/components/memory-config.tsx b/web/app/components/workflow/nodes/_base/components/memory-config.tsx index 15254ad4f4..ed193490a5 100644 --- a/web/app/components/workflow/nodes/_base/components/memory-config.tsx +++ b/web/app/components/workflow/nodes/_base/components/memory-config.tsx @@ -2,13 +2,13 @@ import type { FC } from 'react' import type { Memory } from '../../../types' import { cn } from '@langgenius/dify-ui/cn' +import { Slider } from '@langgenius/dify-ui/slider' +import { Switch } from '@langgenius/dify-ui/switch' import { produce } from 'immer' import * as React from 'react' import { useCallback } from 'react' import { useTranslation } from 'react-i18next' import Input from '@/app/components/base/input' -import Switch from '@/app/components/base/switch' -import { Slider } from '@/app/components/base/ui/slider' import Field from '@/app/components/workflow/nodes/_base/components/field' import { MemoryRole } from '../../../types' diff --git a/web/app/components/workflow/nodes/_base/components/next-step/__tests__/operator.spec.tsx b/web/app/components/workflow/nodes/_base/components/next-step/__tests__/operator.spec.tsx index 9afa29642d..62cf571ae0 100644 --- a/web/app/components/workflow/nodes/_base/components/next-step/__tests__/operator.spec.tsx +++ b/web/app/components/workflow/nodes/_base/components/next-step/__tests__/operator.spec.tsx @@ -18,7 +18,7 @@ vi.mock('react-i18next', () => ({ }), })) -vi.mock('@/app/components/base/ui/dropdown-menu', async () => { +vi.mock('@langgenius/dify-ui/dropdown-menu', async () => { const React = await import('react') const DropdownMenuContext = React.createContext<{ open: boolean, setOpen: (open: boolean) => void } | null>(null) @@ -49,7 +49,7 @@ vi.mock('@/app/components/base/ui/dropdown-menu', async () => { } }) -vi.mock('@/app/components/base/ui/button', () => ({ +vi.mock('@langgenius/dify-ui/button', () => ({ Button: ({ children, className }: { children: ReactNode, className?: string }) => ( ), diff --git a/web/app/components/workflow/nodes/human-input/components/__tests__/user-action.spec.tsx b/web/app/components/workflow/nodes/human-input/components/__tests__/user-action.spec.tsx index 13f8463676..a47a012f49 100644 --- a/web/app/components/workflow/nodes/human-input/components/__tests__/user-action.spec.tsx +++ b/web/app/components/workflow/nodes/human-input/components/__tests__/user-action.spec.tsx @@ -27,7 +27,7 @@ vi.mock('@/app/components/base/input', () => ({ ), })) -vi.mock('@/app/components/base/ui/button', () => ({ +vi.mock('@langgenius/dify-ui/button', () => ({ Button: (props: { children?: ReactNode onClick?: () => void @@ -38,7 +38,7 @@ vi.mock('@/app/components/base/ui/button', () => ({ ), })) -vi.mock('@/app/components/base/ui/toast', () => ({ +vi.mock('@langgenius/dify-ui/toast', () => ({ __esModule: true, toast: { success: (message: string) => mockNotify({ type: 'success', message }), diff --git a/web/app/components/workflow/nodes/human-input/components/button-style-dropdown.tsx b/web/app/components/workflow/nodes/human-input/components/button-style-dropdown.tsx index 363e0070c0..44ddbbfa34 100644 --- a/web/app/components/workflow/nodes/human-input/components/button-style-dropdown.tsx +++ b/web/app/components/workflow/nodes/human-input/components/button-style-dropdown.tsx @@ -1,4 +1,5 @@ import type { FC } from 'react' +import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { RiFontSize, @@ -11,7 +12,6 @@ import { PortalToFollowElemContent, PortalToFollowElemTrigger, } from '@/app/components/base/portal-to-follow-elem' -import { Button } from '@/app/components/base/ui/button' import { UserActionButtonType } from '../types' const i18nPrefix = 'nodes.humanInput' diff --git a/web/app/components/workflow/nodes/human-input/components/delivery-method/__tests__/email-configure-modal.spec.tsx b/web/app/components/workflow/nodes/human-input/components/delivery-method/__tests__/email-configure-modal.spec.tsx index cec9ffe69a..c5b5c680dc 100644 --- a/web/app/components/workflow/nodes/human-input/components/delivery-method/__tests__/email-configure-modal.spec.tsx +++ b/web/app/components/workflow/nodes/human-input/components/delivery-method/__tests__/email-configure-modal.spec.tsx @@ -5,7 +5,7 @@ import EmailConfigureModal from '../email-configure-modal' const mockToastError = vi.hoisted(() => vi.fn()) const mockUseAppContextSelector = vi.hoisted(() => vi.fn()) -vi.mock('@/app/components/base/ui/toast', () => ({ +vi.mock('@langgenius/dify-ui/toast', () => ({ toast: { error: (message: string) => mockToastError(message), }, diff --git a/web/app/components/workflow/nodes/human-input/components/delivery-method/email-configure-modal.tsx b/web/app/components/workflow/nodes/human-input/components/delivery-method/email-configure-modal.tsx index 1d77248037..de38564d95 100644 --- a/web/app/components/workflow/nodes/human-input/components/delivery-method/email-configure-modal.tsx +++ b/web/app/components/workflow/nodes/human-input/components/delivery-method/email-configure-modal.tsx @@ -3,6 +3,9 @@ import type { Node, NodeOutPutVar, } from '@/app/components/workflow/types' +import { Button } from '@langgenius/dify-ui/button' +import { Switch } from '@langgenius/dify-ui/switch' +import { toast } from '@langgenius/dify-ui/toast' import { RiBugLine, RiCloseLine } from '@remixicon/react' import { noop } from 'es-toolkit/compat' import { memo, useCallback, useState } from 'react' @@ -10,9 +13,6 @@ import { Trans, useTranslation } from 'react-i18next' import Divider from '@/app/components/base/divider' import Input from '@/app/components/base/input' import Modal from '@/app/components/base/modal' -import Switch from '@/app/components/base/switch' -import { Button } from '@/app/components/base/ui/button' -import { toast } from '@/app/components/base/ui/toast' import { useSelector as useAppContextWithSelector } from '@/context/app-context' import MailBodyInput from './mail-body-input' import Recipient from './recipient' diff --git a/web/app/components/workflow/nodes/human-input/components/delivery-method/method-item.tsx b/web/app/components/workflow/nodes/human-input/components/delivery-method/method-item.tsx index eae2e293e1..9cd63e96dd 100644 --- a/web/app/components/workflow/nodes/human-input/components/delivery-method/method-item.tsx +++ b/web/app/components/workflow/nodes/human-input/components/delivery-method/method-item.tsx @@ -4,7 +4,9 @@ import type { Node, NodeOutPutVar, } from '@/app/components/workflow/types' +import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' +import { Switch } from '@langgenius/dify-ui/switch' import { RiDeleteBinLine, RiEqualizer2Line, @@ -16,9 +18,7 @@ import { useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import ActionButton, { ActionButtonState } from '@/app/components/base/action-button' import Badge from '@/app/components/base/badge/index' -import Switch from '@/app/components/base/switch' import Tooltip from '@/app/components/base/tooltip' -import { Button } from '@/app/components/base/ui/button' import Indicator from '@/app/components/header/indicator' import { useSelector as useAppContextWithSelector } from '@/context/app-context' import { DeliveryMethodType } from '../../types' diff --git a/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/__tests__/index.spec.tsx b/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/__tests__/index.spec.tsx index 7fe768781b..cd95a8e9e3 100644 --- a/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/__tests__/index.spec.tsx +++ b/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/__tests__/index.spec.tsx @@ -17,9 +17,8 @@ vi.mock('@/service/use-common', () => ({ useMembers: () => mockUseMembers(), })) -vi.mock('@/app/components/base/switch', () => ({ - __esModule: true, - default: (props: { +vi.mock('@langgenius/dify-ui/switch', () => ({ + Switch: (props: { checked: boolean onCheckedChange: (value: boolean) => void }) => ( diff --git a/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/email-item.tsx b/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/email-item.tsx index 8d370266b0..604736b2a9 100644 --- a/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/email-item.tsx +++ b/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/email-item.tsx @@ -1,10 +1,10 @@ import type { Recipient as RecipientItem } from '../../../types' import type { Member } from '@/models/common' +import { Avatar } from '@langgenius/dify-ui/avatar' import { cn } from '@langgenius/dify-ui/cn' import { RiCloseCircleFill, RiErrorWarningFill } from '@remixicon/react' import * as React from 'react' import { useTranslation } from 'react-i18next' -import { Avatar } from '@/app/components/base/ui/avatar' type Props = { email: string diff --git a/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/index.tsx b/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/index.tsx index 36483d33d6..42bd6df171 100644 --- a/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/index.tsx +++ b/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/index.tsx @@ -1,10 +1,10 @@ import type { RecipientData, Recipient as RecipientItem } from '../../../types' import { cn } from '@langgenius/dify-ui/cn' +import { Switch } from '@langgenius/dify-ui/switch' import { RiGroupLine } from '@remixicon/react' import { produce } from 'immer' import { memo } from 'react' import { useTranslation } from 'react-i18next' -import Switch from '@/app/components/base/switch' import { useAppContext } from '@/context/app-context' import { useMembers } from '@/service/use-common' import EmailInput from './email-input' diff --git a/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/member-list.tsx b/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/member-list.tsx index c80c22639e..0e262e839e 100644 --- a/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/member-list.tsx +++ b/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/member-list.tsx @@ -2,11 +2,11 @@ import type { FC } from 'react' import type { Recipient } from '@/app/components/workflow/nodes/human-input/types' import type { Member } from '@/models/common' +import { Avatar } from '@langgenius/dify-ui/avatar' import { cn } from '@langgenius/dify-ui/cn' import { useMemo } from 'react' import { useTranslation } from 'react-i18next' import Input from '@/app/components/base/input' -import { Avatar } from '@/app/components/base/ui/avatar' const i18nPrefix = 'nodes.humanInput' diff --git a/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/member-selector.tsx b/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/member-selector.tsx index 827e02c8bb..c2df6afb15 100644 --- a/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/member-selector.tsx +++ b/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/member-selector.tsx @@ -2,6 +2,7 @@ import type { FC } from 'react' import type { Recipient } from '@/app/components/workflow/nodes/human-input/types' import type { Member } from '@/models/common' +import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { RiContactsBookLine, @@ -9,7 +10,6 @@ import { import { useState } from 'react' import { useTranslation } from 'react-i18next' import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem' -import { Button } from '@/app/components/base/ui/button' import MemberList from './member-list' const i18nPrefix = 'nodes.humanInput' diff --git a/web/app/components/workflow/nodes/human-input/components/delivery-method/test-email-sender.tsx b/web/app/components/workflow/nodes/human-input/components/delivery-method/test-email-sender.tsx index cef6d1380d..9ad52fa13e 100644 --- a/web/app/components/workflow/nodes/human-input/components/delivery-method/test-email-sender.tsx +++ b/web/app/components/workflow/nodes/human-input/components/delivery-method/test-email-sender.tsx @@ -4,6 +4,7 @@ import type { NodeOutPutVar, ValueSelector, } from '@/app/components/workflow/types' +import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { RiArrowRightSFill, RiCloseLine } from '@remixicon/react' import { noop, unionBy } from 'es-toolkit/compat' @@ -13,7 +14,6 @@ import { useStore as useAppStore } from '@/app/components/app/store' import Divider from '@/app/components/base/divider' import Modal from '@/app/components/base/modal' import { getInputVars as doGetInputVars } from '@/app/components/base/prompt-editor/constants' -import { Button } from '@/app/components/base/ui/button' import FormItem from '@/app/components/workflow/nodes/_base/components/before-run-form/form-item' import { getNodeInfoById, diff --git a/web/app/components/workflow/nodes/human-input/components/delivery-method/upgrade-modal.tsx b/web/app/components/workflow/nodes/human-input/components/delivery-method/upgrade-modal.tsx index c7b46732d0..060ec2428c 100644 --- a/web/app/components/workflow/nodes/human-input/components/delivery-method/upgrade-modal.tsx +++ b/web/app/components/workflow/nodes/human-input/components/delivery-method/upgrade-modal.tsx @@ -1,3 +1,4 @@ +import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { RiMailSendFill, @@ -7,7 +8,6 @@ import { useTranslation } from 'react-i18next' import { SparklesSoft } from '@/app/components/base/icons/src/public/common' import Modal from '@/app/components/base/modal' import PremiumBadge from '@/app/components/base/premium-badge' -import { Button } from '@/app/components/base/ui/button' import { useModalContextSelector } from '@/context/modal-context' type UpgradeModalProps = { diff --git a/web/app/components/workflow/nodes/human-input/components/form-content-preview.tsx b/web/app/components/workflow/nodes/human-input/components/form-content-preview.tsx index 1364941edf..73e8850789 100644 --- a/web/app/components/workflow/nodes/human-input/components/form-content-preview.tsx +++ b/web/app/components/workflow/nodes/human-input/components/form-content-preview.tsx @@ -1,14 +1,14 @@ 'use client' +import type { ButtonProps } from '@langgenius/dify-ui/button' import type { FC } from 'react' import type { FormInputItem, UserAction } from '../types' -import type { ButtonProps } from '@/app/components/base/ui/button' +import { Button } from '@langgenius/dify-ui/button' import * as React from 'react' import { useTranslation } from 'react-i18next' import ActionButton from '@/app/components/base/action-button' import Badge from '@/app/components/base/badge' import { getButtonStyle } from '@/app/components/base/chat/chat/answer/human-input-content/utils' import { Markdown } from '@/app/components/base/markdown' -import { Button } from '@/app/components/base/ui/button' import { useStore } from '@/app/components/workflow/store' import useNodes from '@/app/components/workflow/store/workflow/use-nodes' import { Note, rehypeNotes, rehypeVariable, Variable } from './variable-in-markdown' diff --git a/web/app/components/workflow/nodes/human-input/components/single-run-form.tsx b/web/app/components/workflow/nodes/human-input/components/single-run-form.tsx index dc735830c6..6226b6c06f 100644 --- a/web/app/components/workflow/nodes/human-input/components/single-run-form.tsx +++ b/web/app/components/workflow/nodes/human-input/components/single-run-form.tsx @@ -1,15 +1,15 @@ 'use client' -import type { ButtonProps } from '@/app/components/base/ui/button' +import type { ButtonProps } from '@langgenius/dify-ui/button' import type { UserAction } from '@/app/components/workflow/nodes/human-input/types' import type { HumanInputFormData } from '@/types/workflow' +import { Button } from '@langgenius/dify-ui/button' import { RiArrowLeftLine } from '@remixicon/react' -import * as React from 'react' +import * as React from 'react' import { useState } from 'react' import { useTranslation } from 'react-i18next' import ContentItem from '@/app/components/base/chat/chat/answer/human-input-content/content-item' import { getButtonStyle, initializeInputs, splitByOutputVar } from '@/app/components/base/chat/chat/answer/human-input-content/utils' -import { Button } from '@/app/components/base/ui/button' type Props = { nodeName: string diff --git a/web/app/components/workflow/nodes/human-input/components/user-action.tsx b/web/app/components/workflow/nodes/human-input/components/user-action.tsx index 6ef5609a64..a83ea4f8f2 100644 --- a/web/app/components/workflow/nodes/human-input/components/user-action.tsx +++ b/web/app/components/workflow/nodes/human-input/components/user-action.tsx @@ -1,13 +1,13 @@ import type { FC } from 'react' import type { UserAction } from '../types' +import { Button } from '@langgenius/dify-ui/button' +import { toast } from '@langgenius/dify-ui/toast' import { RiDeleteBinLine, } from '@remixicon/react' import * as React from 'react' import { useTranslation } from 'react-i18next' import Input from '@/app/components/base/input' -import { Button } from '@/app/components/base/ui/button' -import { toast } from '@/app/components/base/ui/toast' import ButtonStyleDropdown from './button-style-dropdown' const i18nPrefix = 'nodes.humanInput' diff --git a/web/app/components/workflow/nodes/human-input/panel.tsx b/web/app/components/workflow/nodes/human-input/panel.tsx index 846b135fcf..b7b65e7de8 100644 --- a/web/app/components/workflow/nodes/human-input/panel.tsx +++ b/web/app/components/workflow/nodes/human-input/panel.tsx @@ -1,7 +1,9 @@ import type { FC } from 'react' import type { HumanInputNodeType } from './types' import type { NodePanelProps, Var } from '@/app/components/workflow/types' +import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' +import { toast } from '@langgenius/dify-ui/toast' import { RiAddLine, RiClipboardLine, @@ -17,8 +19,6 @@ import { useTranslation } from 'react-i18next' import ActionButton from '@/app/components/base/action-button' import Divider from '@/app/components/base/divider' import Tooltip from '@/app/components/base/tooltip' -import { Button } from '@/app/components/base/ui/button' -import { toast } from '@/app/components/base/ui/toast' import OutputVars, { VarItem } from '@/app/components/workflow/nodes/_base/components/output-vars' import Split from '@/app/components/workflow/nodes/_base/components/split' import useAvailableVarList from '@/app/components/workflow/nodes/_base/hooks/use-available-var-list' diff --git a/web/app/components/workflow/nodes/if-else/components/condition-add.tsx b/web/app/components/workflow/nodes/if-else/components/condition-add.tsx index 3b6d729078..85a45ac5c7 100644 --- a/web/app/components/workflow/nodes/if-else/components/condition-add.tsx +++ b/web/app/components/workflow/nodes/if-else/components/condition-add.tsx @@ -4,6 +4,7 @@ import type { ValueSelector, Var, } from '@/app/components/workflow/types' +import { Button } from '@langgenius/dify-ui/button' import { RiAddLine } from '@remixicon/react' import { useCallback, @@ -15,7 +16,6 @@ import { PortalToFollowElemContent, PortalToFollowElemTrigger, } from '@/app/components/base/portal-to-follow-elem' -import { Button } from '@/app/components/base/ui/button' import VarReferenceVars from '@/app/components/workflow/nodes/_base/components/variable/var-reference-vars' type ConditionAddProps = { diff --git a/web/app/components/workflow/nodes/if-else/components/condition-list/condition-operator.tsx b/web/app/components/workflow/nodes/if-else/components/condition-list/condition-operator.tsx index 998cc48205..83dc97f088 100644 --- a/web/app/components/workflow/nodes/if-else/components/condition-list/condition-operator.tsx +++ b/web/app/components/workflow/nodes/if-else/components/condition-list/condition-operator.tsx @@ -1,5 +1,6 @@ import type { ComparisonOperator } from '../../types' import type { VarType } from '@/app/components/workflow/types' +import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { RiArrowDownSLine } from '@remixicon/react' import { @@ -12,7 +13,6 @@ import { PortalToFollowElemContent, PortalToFollowElemTrigger, } from '@/app/components/base/portal-to-follow-elem' -import { Button } from '@/app/components/base/ui/button' import { getOperators, isComparisonOperatorNeedTranslate } from '../../utils' const i18nPrefix = 'nodes.ifElse' diff --git a/web/app/components/workflow/nodes/if-else/components/condition-number-input.tsx b/web/app/components/workflow/nodes/if-else/components/condition-number-input.tsx index 7bcb4137b5..954f713a78 100644 --- a/web/app/components/workflow/nodes/if-else/components/condition-number-input.tsx +++ b/web/app/components/workflow/nodes/if-else/components/condition-number-input.tsx @@ -2,6 +2,7 @@ import type { NodeOutPutVar, ValueSelector, } from '@/app/components/workflow/types' +import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { RiArrowDownSLine } from '@remixicon/react' import { useBoolean } from 'ahooks' @@ -18,7 +19,6 @@ import { PortalToFollowElemContent, PortalToFollowElemTrigger, } from '@/app/components/base/portal-to-follow-elem' -import { Button } from '@/app/components/base/ui/button' import VarReferenceVars from '@/app/components/workflow/nodes/_base/components/variable/var-reference-vars' import { VarType } from '@/app/components/workflow/types' import { variableTransformer } from '@/app/components/workflow/utils' diff --git a/web/app/components/workflow/nodes/if-else/components/condition-wrap.tsx b/web/app/components/workflow/nodes/if-else/components/condition-wrap.tsx index 1744ee6490..92d70b37b1 100644 --- a/web/app/components/workflow/nodes/if-else/components/condition-wrap.tsx +++ b/web/app/components/workflow/nodes/if-else/components/condition-wrap.tsx @@ -2,6 +2,7 @@ import type { FC } from 'react' import type { Node, NodeOutPutVar, Var } from '../../../types' import type { CaseItem, HandleAddCondition, HandleAddSubVariableCondition, HandleRemoveCondition, handleRemoveSubVariableCondition, HandleToggleConditionLogicalOperator, HandleToggleSubVariableConditionLogicalOperator, HandleUpdateCondition, HandleUpdateSubVariableCondition } from '../types' +import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { RiAddLine, @@ -14,7 +15,6 @@ import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import { ReactSortable } from 'react-sortablejs' import { PortalSelect as Select } from '@/app/components/base/select' -import { Button } from '@/app/components/base/ui/button' import { VarType } from '../../../types' import { SUB_VARIABLES } from '../../constants' import { useGetAvailableVars } from '../../variable-assigner/hooks' diff --git a/web/app/components/workflow/nodes/if-else/panel.tsx b/web/app/components/workflow/nodes/if-else/panel.tsx index e8dae6fad9..221b00d1f7 100644 --- a/web/app/components/workflow/nodes/if-else/panel.tsx +++ b/web/app/components/workflow/nodes/if-else/panel.tsx @@ -1,6 +1,7 @@ import type { FC } from 'react' import type { IfElseNodeType } from './types' import type { NodePanelProps } from '@/app/components/workflow/types' +import { Button } from '@langgenius/dify-ui/button' import { RiAddLine, } from '@remixicon/react' @@ -8,7 +9,6 @@ import { memo, } from 'react' import { useTranslation } from 'react-i18next' -import { Button } from '@/app/components/base/ui/button' import Field from '@/app/components/workflow/nodes/_base/components/field' import ConditionWrap from './components/condition-wrap' import useConfig from './use-config' diff --git a/web/app/components/workflow/nodes/iteration/__tests__/integration.spec.tsx b/web/app/components/workflow/nodes/iteration/__tests__/integration.spec.tsx index dc7538144e..26b938bf6e 100644 --- a/web/app/components/workflow/nodes/iteration/__tests__/integration.spec.tsx +++ b/web/app/components/workflow/nodes/iteration/__tests__/integration.spec.tsx @@ -1,9 +1,9 @@ import type { ReactNode } from 'react' import type { IterationNodeType } from '../types' import type { PanelProps } from '@/types/workflow' +import { toast } from '@langgenius/dify-ui/toast' import { fireEvent, render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { toast } from '@/app/components/base/ui/toast' import { ErrorHandleMode } from '@/app/components/workflow/types' import { BlockEnum, VarType } from '../../../types' import AddBlock from '../add-block' @@ -15,7 +15,7 @@ const mockHandleNodeAdd = vi.fn() const mockHandleNodeIterationRerender = vi.fn() let mockNodesReadOnly = false -vi.mock('@/app/components/base/ui/toast', () => ({ +vi.mock('@langgenius/dify-ui/toast', () => ({ toast: { success: vi.fn(), error: vi.fn(), diff --git a/web/app/components/workflow/nodes/iteration/node.tsx b/web/app/components/workflow/nodes/iteration/node.tsx index ad9cb3b0fe..a3aee372d7 100644 --- a/web/app/components/workflow/nodes/iteration/node.tsx +++ b/web/app/components/workflow/nodes/iteration/node.tsx @@ -2,6 +2,7 @@ import type { FC } from 'react' import type { IterationNodeType } from './types' import type { NodeProps } from '@/app/components/workflow/types' import { cn } from '@langgenius/dify-ui/cn' +import { toast } from '@langgenius/dify-ui/toast' import { memo, useEffect, @@ -13,7 +14,6 @@ import { useNodesInitialized, useViewport, } from 'reactflow' -import { toast } from '@/app/components/base/ui/toast' import { IterationStartNodeDumb } from '../iteration-start' import AddBlock from './add-block' import { useNodeIterationInteractions } from './use-interactions' diff --git a/web/app/components/workflow/nodes/iteration/panel.tsx b/web/app/components/workflow/nodes/iteration/panel.tsx index 7128c2d4b4..c9ed2de363 100644 --- a/web/app/components/workflow/nodes/iteration/panel.tsx +++ b/web/app/components/workflow/nodes/iteration/panel.tsx @@ -1,12 +1,12 @@ import type { FC } from 'react' import type { IterationNodeType } from './types' import type { NodePanelProps } from '@/app/components/workflow/types' +import { Slider } from '@langgenius/dify-ui/slider' +import { Switch } from '@langgenius/dify-ui/switch' import * as React from 'react' import { useTranslation } from 'react-i18next' import Input from '@/app/components/base/input' import Select from '@/app/components/base/select' -import Switch from '@/app/components/base/switch' -import { Slider } from '@/app/components/base/ui/slider' import Field from '@/app/components/workflow/nodes/_base/components/field' import { ErrorHandleMode } from '@/app/components/workflow/types' import { MAX_PARALLEL_LIMIT } from '@/config' diff --git a/web/app/components/workflow/nodes/knowledge-base/components/chunk-structure/index.tsx b/web/app/components/workflow/nodes/knowledge-base/components/chunk-structure/index.tsx index a7bba6879e..0920e8a8a7 100644 --- a/web/app/components/workflow/nodes/knowledge-base/components/chunk-structure/index.tsx +++ b/web/app/components/workflow/nodes/knowledge-base/components/chunk-structure/index.tsx @@ -1,7 +1,7 @@ import type { ChunkStructureEnum } from '../../types' +import { Button } from '@langgenius/dify-ui/button' import { memo } from 'react' import { useTranslation } from 'react-i18next' -import { Button } from '@/app/components/base/ui/button' import { Field } from '@/app/components/workflow/nodes/_base/components/layout' import OptionCard from '../option-card' import { useChunkStructure } from './hooks' diff --git a/web/app/components/workflow/nodes/knowledge-base/components/chunk-structure/selector.tsx b/web/app/components/workflow/nodes/knowledge-base/components/chunk-structure/selector.tsx index 5018bbc784..7ab8de508f 100644 --- a/web/app/components/workflow/nodes/knowledge-base/components/chunk-structure/selector.tsx +++ b/web/app/components/workflow/nodes/knowledge-base/components/chunk-structure/selector.tsx @@ -1,6 +1,7 @@ import type { ReactNode } from 'react' import type { ChunkStructureEnum } from '../../types' import type { Option } from './type' +import { Button } from '@langgenius/dify-ui/button' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import { @@ -8,7 +9,6 @@ import { PortalToFollowElemContent, PortalToFollowElemTrigger, } from '@/app/components/base/portal-to-follow-elem' -import { Button } from '@/app/components/base/ui/button' import OptionCard from '../option-card' type SelectorProps = { diff --git a/web/app/components/workflow/nodes/knowledge-base/components/index-method.tsx b/web/app/components/workflow/nodes/knowledge-base/components/index-method.tsx index 12cc0f1eb5..70ff0e47eb 100644 --- a/web/app/components/workflow/nodes/knowledge-base/components/index-method.tsx +++ b/web/app/components/workflow/nodes/knowledge-base/components/index-method.tsx @@ -1,4 +1,5 @@ import { cn } from '@langgenius/dify-ui/cn' +import { Slider } from '@langgenius/dify-ui/slider' import { RiQuestionLine } from '@remixicon/react' import { memo, @@ -11,7 +12,6 @@ import { } from '@/app/components/base/icons/src/vender/knowledge' import Input from '@/app/components/base/input' import Tooltip from '@/app/components/base/tooltip' -import { Slider } from '@/app/components/base/ui/slider' import { Field } from '@/app/components/workflow/nodes/_base/components/layout' import { ChunkStructureEnum, diff --git a/web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/search-method-option.tsx b/web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/search-method-option.tsx index fe1d863944..a1f601cce9 100644 --- a/web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/search-method-option.tsx +++ b/web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/search-method-option.tsx @@ -8,6 +8,7 @@ import type { Option, } from './type' import { cn } from '@langgenius/dify-ui/cn' +import { Switch } from '@langgenius/dify-ui/switch' import { memo, useCallback, @@ -16,7 +17,6 @@ import { import { useTranslation } from 'react-i18next' import WeightedScoreComponent from '@/app/components/app/configuration/dataset-config/params-config/weighted-score' import { AlertTriangle } from '@/app/components/base/icons/src/vender/solid/alertsAndFeedback' -import Switch from '@/app/components/base/switch' import Tooltip from '@/app/components/base/tooltip' import { DEFAULT_WEIGHTED_SCORE } from '@/models/datasets' import { diff --git a/web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/top-k-and-score-threshold.tsx b/web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/top-k-and-score-threshold.tsx index 5136d4897f..814b3cea6d 100644 --- a/web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/top-k-and-score-threshold.tsx +++ b/web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/top-k-and-score-threshold.tsx @@ -1,7 +1,3 @@ -import { memo, useCallback } from 'react' -import { useTranslation } from 'react-i18next' -import Switch from '@/app/components/base/switch' -import Tooltip from '@/app/components/base/tooltip' import { NumberField, NumberFieldControls, @@ -9,7 +5,11 @@ import { NumberFieldGroup, NumberFieldIncrement, NumberFieldInput, -} from '@/app/components/base/ui/number-field' +} from '@langgenius/dify-ui/number-field' +import { Switch } from '@langgenius/dify-ui/switch' +import { memo, useCallback } from 'react' +import { useTranslation } from 'react-i18next' +import Tooltip from '@/app/components/base/tooltip' import { env } from '@/env' export type TopKAndScoreThresholdProps = { diff --git a/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/add-condition.tsx b/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/add-condition.tsx index e94caf7b30..ea7870431a 100644 --- a/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/add-condition.tsx +++ b/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/add-condition.tsx @@ -1,5 +1,6 @@ import type { MetadataShape } from '@/app/components/workflow/nodes/knowledge-retrieval/types' import type { MetadataInDoc } from '@/models/datasets' +import { Button } from '@langgenius/dify-ui/button' import { RiAddLine, } from '@remixicon/react' @@ -15,7 +16,6 @@ import { PortalToFollowElemContent, PortalToFollowElemTrigger, } from '@/app/components/base/portal-to-follow-elem' -import { Button } from '@/app/components/base/ui/button' import MetadataIcon from './metadata-icon' const AddCondition = ({ diff --git a/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/condition-list/condition-operator.tsx b/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/condition-list/condition-operator.tsx index d6828a5392..f14743ce9f 100644 --- a/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/condition-list/condition-operator.tsx +++ b/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/condition-list/condition-operator.tsx @@ -2,6 +2,7 @@ import type { ComparisonOperator, MetadataFilteringVariableType, } from '@/app/components/workflow/nodes/knowledge-retrieval/types' +import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { RiArrowDownSLine } from '@remixicon/react' import { @@ -14,7 +15,6 @@ import { PortalToFollowElemContent, PortalToFollowElemTrigger, } from '@/app/components/base/portal-to-follow-elem' -import { Button } from '@/app/components/base/ui/button' import { getOperators, isComparisonOperatorNeedTranslate, diff --git a/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/condition-list/condition-value-method.tsx b/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/condition-list/condition-value-method.tsx index b69bd05cdd..12d7c9cc69 100644 --- a/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/condition-list/condition-value-method.tsx +++ b/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/condition-list/condition-value-method.tsx @@ -1,3 +1,4 @@ +import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { RiArrowDownSLine } from '@remixicon/react' import { capitalize } from 'es-toolkit/string' @@ -7,7 +8,6 @@ import { PortalToFollowElemContent, PortalToFollowElemTrigger, } from '@/app/components/base/portal-to-follow-elem' -import { Button } from '@/app/components/base/ui/button' export type ConditionValueMethodProps = { valueMethod?: string diff --git a/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/metadata-filter/metadata-filter-selector.tsx b/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/metadata-filter/metadata-filter-selector.tsx index 67939a6448..396fd069e7 100644 --- a/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/metadata-filter/metadata-filter-selector.tsx +++ b/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/metadata-filter/metadata-filter-selector.tsx @@ -1,3 +1,4 @@ +import { Button } from '@langgenius/dify-ui/button' import { RiArrowDownSLine, RiCheckLine, @@ -9,7 +10,6 @@ import { PortalToFollowElemContent, PortalToFollowElemTrigger, } from '@/app/components/base/portal-to-follow-elem' -import { Button } from '@/app/components/base/ui/button' import { MetadataFilteringModeEnum } from '@/app/components/workflow/nodes/knowledge-retrieval/types' type MetadataFilterSelectorProps = { diff --git a/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/metadata-trigger.tsx b/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/metadata-trigger.tsx index 1553da1e1f..dd530ae679 100644 --- a/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/metadata-trigger.tsx +++ b/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/metadata-trigger.tsx @@ -1,4 +1,5 @@ import type { MetadataShape } from '@/app/components/workflow/nodes/knowledge-retrieval/types' +import { Button } from '@langgenius/dify-ui/button' import { RiFilter3Line } from '@remixicon/react' import { useEffect, @@ -10,7 +11,6 @@ import { PortalToFollowElemContent, PortalToFollowElemTrigger, } from '@/app/components/base/portal-to-follow-elem' -import { Button } from '@/app/components/base/ui/button' import MetadataPanel from './metadata-panel' const MetadataTrigger = ({ diff --git a/web/app/components/workflow/nodes/knowledge-retrieval/components/retrieval-config.tsx b/web/app/components/workflow/nodes/knowledge-retrieval/components/retrieval-config.tsx index 3f8198a822..da71682b35 100644 --- a/web/app/components/workflow/nodes/knowledge-retrieval/components/retrieval-config.tsx +++ b/web/app/components/workflow/nodes/knowledge-retrieval/components/retrieval-config.tsx @@ -5,6 +5,7 @@ import type { MultipleRetrievalConfig, SingleRetrievalConfig } from '../types' import type { ModelParameterModalProps } from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal' import type { DataSet } from '@/models/datasets' import type { DatasetConfigs } from '@/models/debug' +import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { RiEqualizer2Line } from '@remixicon/react' import * as React from 'react' @@ -16,7 +17,6 @@ import { PortalToFollowElemContent, PortalToFollowElemTrigger, } from '@/app/components/base/portal-to-follow-elem' -import { Button } from '@/app/components/base/ui/button' import { DATASET_DEFAULT } from '@/config' import { RETRIEVE_TYPE } from '@/types/app' diff --git a/web/app/components/workflow/nodes/list-operator/__tests__/panel.spec.tsx b/web/app/components/workflow/nodes/list-operator/__tests__/panel.spec.tsx index 9e5147bc92..945ca70104 100644 --- a/web/app/components/workflow/nodes/list-operator/__tests__/panel.spec.tsx +++ b/web/app/components/workflow/nodes/list-operator/__tests__/panel.spec.tsx @@ -22,9 +22,8 @@ vi.mock('../use-config', () => ({ default: (...args: unknown[]) => mockUseConfig(...args), })) -vi.mock('@/app/components/base/switch', () => ({ - __esModule: true, - default: (props: { +vi.mock('@langgenius/dify-ui/switch', () => ({ + Switch: (props: { checked?: boolean disabled?: boolean onCheckedChange: (value: boolean) => void diff --git a/web/app/components/workflow/nodes/list-operator/components/__tests__/limit-config.spec.tsx b/web/app/components/workflow/nodes/list-operator/components/__tests__/limit-config.spec.tsx index fbc3356b32..1314b4600b 100644 --- a/web/app/components/workflow/nodes/list-operator/components/__tests__/limit-config.spec.tsx +++ b/web/app/components/workflow/nodes/list-operator/components/__tests__/limit-config.spec.tsx @@ -19,8 +19,8 @@ type MockSliderProps = { const mockSwitch = vi.fn<(props: MockSwitchProps) => void>() const mockSlider = vi.fn<(props: MockSliderProps) => void>() -vi.mock('@/app/components/base/switch', () => ({ - default: (props: MockSwitchProps) => { +vi.mock('@langgenius/dify-ui/switch', () => ({ + Switch: (props: MockSwitchProps) => { mockSwitch(props) return ( diff --git a/web/app/components/workflow/panel/comments-panel/index.tsx b/web/app/components/workflow/panel/comments-panel/index.tsx index 21b3c3c4e6..05480abb2b 100644 --- a/web/app/components/workflow/panel/comments-panel/index.tsx +++ b/web/app/components/workflow/panel/comments-panel/index.tsx @@ -1,10 +1,10 @@ import type { WorkflowCommentList } from '@/service/workflow-comment' import { cn } from '@langgenius/dify-ui/cn' +import { Switch } from '@langgenius/dify-ui/switch' import { RiCheckboxCircleFill, RiCheckboxCircleLine, RiCheckLine, RiCloseLine, RiFilter3Line } from '@remixicon/react' import { memo, useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import Divider from '@/app/components/base/divider' -import Switch from '@/app/components/base/switch' import { UserAvatarList } from '@/app/components/base/user-avatar-list' import { collaborationManager } from '@/app/components/workflow/collaboration/core/collaboration-manager' import { useWorkflowComment } from '@/app/components/workflow/hooks/use-workflow-comment' diff --git a/web/app/components/workflow/panel/debug-and-preview/__tests__/hooks.spec.ts b/web/app/components/workflow/panel/debug-and-preview/__tests__/hooks.spec.ts index 425dd90e87..9a2b8a8df7 100644 --- a/web/app/components/workflow/panel/debug-and-preview/__tests__/hooks.spec.ts +++ b/web/app/components/workflow/panel/debug-and-preview/__tests__/hooks.spec.ts @@ -26,7 +26,7 @@ vi.mock('@/service/workflow', () => ({ submitHumanInputForm: (...args: unknown[]) => mockSubmitHumanInputForm(...args), })) -vi.mock('@/app/components/base/ui/toast', () => ({ +vi.mock('@langgenius/dify-ui/toast', () => ({ toast: { success: vi.fn(), error: vi.fn(), diff --git a/web/app/components/workflow/panel/debug-and-preview/__tests__/hooks/handle-resume.spec.ts b/web/app/components/workflow/panel/debug-and-preview/__tests__/hooks/handle-resume.spec.ts index 04c19f50e9..5a608dcc5b 100644 --- a/web/app/components/workflow/panel/debug-and-preview/__tests__/hooks/handle-resume.spec.ts +++ b/web/app/components/workflow/panel/debug-and-preview/__tests__/hooks/handle-resume.spec.ts @@ -27,7 +27,7 @@ vi.mock('@/service/workflow', () => ({ submitHumanInputForm: (...args: any[]) => mockSubmitHumanInputForm(...args), })) -vi.mock('@/app/components/base/ui/toast', () => ({ +vi.mock('@langgenius/dify-ui/toast', () => ({ toast: { success: vi.fn(), error: vi.fn(), diff --git a/web/app/components/workflow/panel/debug-and-preview/__tests__/hooks/handle-send.spec.ts b/web/app/components/workflow/panel/debug-and-preview/__tests__/hooks/handle-send.spec.ts index 0f6ae5d713..fb045c4723 100644 --- a/web/app/components/workflow/panel/debug-and-preview/__tests__/hooks/handle-send.spec.ts +++ b/web/app/components/workflow/panel/debug-and-preview/__tests__/hooks/handle-send.spec.ts @@ -26,7 +26,7 @@ vi.mock('@/service/workflow', () => ({ submitHumanInputForm: (...args: any[]) => mockSubmitHumanInputForm(...args), })) -vi.mock('@/app/components/base/ui/toast', () => ({ +vi.mock('@langgenius/dify-ui/toast', () => ({ toast: { success: vi.fn(), error: vi.fn(), diff --git a/web/app/components/workflow/panel/debug-and-preview/__tests__/hooks/handle-stop-restart.spec.ts b/web/app/components/workflow/panel/debug-and-preview/__tests__/hooks/handle-stop-restart.spec.ts index d0b1908606..2868471bd3 100644 --- a/web/app/components/workflow/panel/debug-and-preview/__tests__/hooks/handle-stop-restart.spec.ts +++ b/web/app/components/workflow/panel/debug-and-preview/__tests__/hooks/handle-stop-restart.spec.ts @@ -27,7 +27,7 @@ vi.mock('@/service/workflow', () => ({ submitHumanInputForm: (...args: any[]) => mockSubmitHumanInputForm(...args), })) -vi.mock('@/app/components/base/ui/toast', () => ({ +vi.mock('@langgenius/dify-ui/toast', () => ({ toast: { success: vi.fn(), error: vi.fn(), diff --git a/web/app/components/workflow/panel/debug-and-preview/__tests__/hooks/misc.spec.ts b/web/app/components/workflow/panel/debug-and-preview/__tests__/hooks/misc.spec.ts index 549fe08e05..cd2eaa0e00 100644 --- a/web/app/components/workflow/panel/debug-and-preview/__tests__/hooks/misc.spec.ts +++ b/web/app/components/workflow/panel/debug-and-preview/__tests__/hooks/misc.spec.ts @@ -27,7 +27,7 @@ vi.mock('@/service/workflow', () => ({ submitHumanInputForm: (...args: any[]) => mockSubmitHumanInputForm(...args), })) -vi.mock('@/app/components/base/ui/toast', () => ({ +vi.mock('@langgenius/dify-ui/toast', () => ({ toast: { success: vi.fn(), error: vi.fn(), diff --git a/web/app/components/workflow/panel/debug-and-preview/__tests__/hooks/opening-statement.spec.ts b/web/app/components/workflow/panel/debug-and-preview/__tests__/hooks/opening-statement.spec.ts index 57db5502b5..cb45029340 100644 --- a/web/app/components/workflow/panel/debug-and-preview/__tests__/hooks/opening-statement.spec.ts +++ b/web/app/components/workflow/panel/debug-and-preview/__tests__/hooks/opening-statement.spec.ts @@ -27,7 +27,7 @@ vi.mock('@/service/workflow', () => ({ submitHumanInputForm: (...args: any[]) => mockSubmitHumanInputForm(...args), })) -vi.mock('@/app/components/base/ui/toast', () => ({ +vi.mock('@langgenius/dify-ui/toast', () => ({ toast: { success: vi.fn(), error: vi.fn(), diff --git a/web/app/components/workflow/panel/debug-and-preview/__tests__/hooks/sse-callbacks.spec.ts b/web/app/components/workflow/panel/debug-and-preview/__tests__/hooks/sse-callbacks.spec.ts index 03857ce3d6..f8f3a02c5c 100644 --- a/web/app/components/workflow/panel/debug-and-preview/__tests__/hooks/sse-callbacks.spec.ts +++ b/web/app/components/workflow/panel/debug-and-preview/__tests__/hooks/sse-callbacks.spec.ts @@ -26,7 +26,7 @@ vi.mock('@/service/workflow', () => ({ submitHumanInputForm: (...args: any[]) => mockSubmitHumanInputForm(...args), })) -vi.mock('@/app/components/base/ui/toast', () => ({ +vi.mock('@langgenius/dify-ui/toast', () => ({ toast: { success: vi.fn(), error: vi.fn(), diff --git a/web/app/components/workflow/panel/debug-and-preview/hooks.ts b/web/app/components/workflow/panel/debug-and-preview/hooks.ts index 6eef69bfdb..a94e4c1edd 100644 --- a/web/app/components/workflow/panel/debug-and-preview/hooks.ts +++ b/web/app/components/workflow/panel/debug-and-preview/hooks.ts @@ -6,6 +6,7 @@ import type { } from '@/app/components/base/chat/types' import type { FileEntity } from '@/app/components/base/file-uploader/types' import type { IOtherOptions } from '@/service/base' +import { toast } from '@langgenius/dify-ui/toast' import { uniqBy } from 'es-toolkit/compat' import { produce, setAutoFreeze } from 'immer' import { @@ -26,7 +27,6 @@ import { getProcessedFiles, getProcessedFilesFromResponse, } from '@/app/components/base/file-uploader/utils' -import { toast } from '@/app/components/base/ui/toast' import { CUSTOM_NODE, } from '@/app/components/workflow/constants' diff --git a/web/app/components/workflow/panel/env-panel/__tests__/variable-modal.spec.tsx b/web/app/components/workflow/panel/env-panel/__tests__/variable-modal.spec.tsx index 7e97ef62dc..258784b692 100644 --- a/web/app/components/workflow/panel/env-panel/__tests__/variable-modal.spec.tsx +++ b/web/app/components/workflow/panel/env-panel/__tests__/variable-modal.spec.tsx @@ -1,14 +1,14 @@ import type { ReactElement } from 'react' import type { Shape } from '@/app/components/workflow/store/workflow' import type { EnvironmentVariable } from '@/app/components/workflow/types' +import { toast } from '@langgenius/dify-ui/toast' import { fireEvent, render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { toast } from '@/app/components/base/ui/toast' import { WorkflowContext } from '@/app/components/workflow/context' import { createWorkflowStore } from '@/app/components/workflow/store/workflow' import VariableModal from '../variable-modal' -vi.mock('@/app/components/base/ui/toast', () => ({ +vi.mock('@langgenius/dify-ui/toast', () => ({ toast: { success: vi.fn(), error: vi.fn(), diff --git a/web/app/components/workflow/panel/env-panel/variable-modal.tsx b/web/app/components/workflow/panel/env-panel/variable-modal.tsx index e824dd9f08..267c014e1d 100644 --- a/web/app/components/workflow/panel/env-panel/variable-modal.tsx +++ b/web/app/components/workflow/panel/env-panel/variable-modal.tsx @@ -1,5 +1,7 @@ import type { EnvironmentVariable } from '@/app/components/workflow/types' +import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' +import { toast } from '@langgenius/dify-ui/toast' import { RiCloseLine } from '@remixicon/react' import * as React from 'react' import { useEffect } from 'react' @@ -7,8 +9,6 @@ import { useTranslation } from 'react-i18next' import { v4 as uuid4 } from 'uuid' import Input from '@/app/components/base/input' import Tooltip from '@/app/components/base/tooltip' -import { Button } from '@/app/components/base/ui/button' -import { toast } from '@/app/components/base/ui/toast' import { useWorkflowStore } from '@/app/components/workflow/store' import { checkKeys, replaceSpaceWithUnderscoreInVarNameInput } from '@/utils/var' diff --git a/web/app/components/workflow/panel/env-panel/variable-trigger.tsx b/web/app/components/workflow/panel/env-panel/variable-trigger.tsx index e075238e48..378fc1c1b5 100644 --- a/web/app/components/workflow/panel/env-panel/variable-trigger.tsx +++ b/web/app/components/workflow/panel/env-panel/variable-trigger.tsx @@ -1,10 +1,10 @@ 'use client' import type { EnvironmentVariable } from '@/app/components/workflow/types' +import { Button } from '@langgenius/dify-ui/button' +import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import { RiAddLine } from '@remixicon/react' import * as React from 'react' import { useTranslation } from 'react-i18next' -import { Button } from '@/app/components/base/ui/button' -import { Popover, PopoverContent, PopoverTrigger } from '@/app/components/base/ui/popover' import VariableModal from '@/app/components/workflow/panel/env-panel/variable-modal' type Props = { diff --git a/web/app/components/workflow/panel/inputs-panel.tsx b/web/app/components/workflow/panel/inputs-panel.tsx index 4e6255a2c0..10004236dd 100644 --- a/web/app/components/workflow/panel/inputs-panel.tsx +++ b/web/app/components/workflow/panel/inputs-panel.tsx @@ -1,4 +1,5 @@ import type { StartNodeType } from '../nodes/start/types' +import { Button } from '@langgenius/dify-ui/button' import { memo, useCallback, @@ -10,7 +11,6 @@ import { useCheckInputsForms } from '@/app/components/base/chat/chat/check-input import { getProcessedInputs, } from '@/app/components/base/chat/chat/utils' -import { Button } from '@/app/components/base/ui/button' import { TransferMethod } from '../../base/text-generation/types' import { useWorkflowRun } from '../hooks' import { useHooksStore } from '../hooks-store' diff --git a/web/app/components/workflow/panel/version-history-panel/context-menu/__tests__/menu-item.spec.tsx b/web/app/components/workflow/panel/version-history-panel/context-menu/__tests__/menu-item.spec.tsx index 844e189067..7dfc362a90 100644 --- a/web/app/components/workflow/panel/version-history-panel/context-menu/__tests__/menu-item.spec.tsx +++ b/web/app/components/workflow/panel/version-history-panel/context-menu/__tests__/menu-item.spec.tsx @@ -1,6 +1,6 @@ +import { DropdownMenu, DropdownMenuContent } from '@langgenius/dify-ui/dropdown-menu' import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { DropdownMenu, DropdownMenuContent } from '@/app/components/base/ui/dropdown-menu' import { VersionHistoryContextMenuOptions } from '../../../../types' import MenuItem from '../menu-item' diff --git a/web/app/components/workflow/panel/version-history-panel/context-menu/index.tsx b/web/app/components/workflow/panel/version-history-panel/context-menu/index.tsx index f063902753..1b90166f65 100644 --- a/web/app/components/workflow/panel/version-history-panel/context-menu/index.tsx +++ b/web/app/components/workflow/panel/version-history-panel/context-menu/index.tsx @@ -1,13 +1,13 @@ import type { FC } from 'react' -import { RiMoreFill } from '@remixicon/react' -import * as React from 'react' -import { Button } from '@/app/components/base/ui/button' +import { Button } from '@langgenius/dify-ui/button' import { DropdownMenu, DropdownMenuContent, DropdownMenuSeparator, DropdownMenuTrigger, -} from '@/app/components/base/ui/dropdown-menu' +} from '@langgenius/dify-ui/dropdown-menu' +import { RiMoreFill } from '@remixicon/react' +import * as React from 'react' import { VersionHistoryContextMenuOptions } from '../../../types' import MenuItem from './menu-item' import useContextMenu from './use-context-menu' diff --git a/web/app/components/workflow/panel/version-history-panel/context-menu/menu-item.tsx b/web/app/components/workflow/panel/version-history-panel/context-menu/menu-item.tsx index b1d9ef6ec5..0c0096ab25 100644 --- a/web/app/components/workflow/panel/version-history-panel/context-menu/menu-item.tsx +++ b/web/app/components/workflow/panel/version-history-panel/context-menu/menu-item.tsx @@ -1,8 +1,8 @@ import type { FC } from 'react' import type { VersionHistoryContextMenuOptions } from '../../../types' import { cn } from '@langgenius/dify-ui/cn' +import { DropdownMenuItem } from '@langgenius/dify-ui/dropdown-menu' import * as React from 'react' -import { DropdownMenuItem } from '@/app/components/base/ui/dropdown-menu' type MenuItemProps = { item: { diff --git a/web/app/components/workflow/panel/version-history-panel/delete-confirm-modal.tsx b/web/app/components/workflow/panel/version-history-panel/delete-confirm-modal.tsx index 4779be8984..1f7948c41b 100644 --- a/web/app/components/workflow/panel/version-history-panel/delete-confirm-modal.tsx +++ b/web/app/components/workflow/panel/version-history-panel/delete-confirm-modal.tsx @@ -1,9 +1,9 @@ import type { FC } from 'react' import type { VersionHistory } from '@/types/workflow' +import { Button } from '@langgenius/dify-ui/button' import * as React from 'react' import { useTranslation } from 'react-i18next' import Modal from '@/app/components/base/modal' -import { Button } from '@/app/components/base/ui/button' type DeleteConfirmModalProps = { isOpen: boolean diff --git a/web/app/components/workflow/panel/version-history-panel/empty.tsx b/web/app/components/workflow/panel/version-history-panel/empty.tsx index 2c8f6655e1..21751234d7 100644 --- a/web/app/components/workflow/panel/version-history-panel/empty.tsx +++ b/web/app/components/workflow/panel/version-history-panel/empty.tsx @@ -1,8 +1,8 @@ import type { FC } from 'react' +import { Button } from '@langgenius/dify-ui/button' import { RiHistoryLine } from '@remixicon/react' import * as React from 'react' import { useTranslation } from 'react-i18next' -import { Button } from '@/app/components/base/ui/button' type EmptyProps = { onResetFilter: () => void diff --git a/web/app/components/workflow/panel/version-history-panel/filter/filter-switch.tsx b/web/app/components/workflow/panel/version-history-panel/filter/filter-switch.tsx index 1df519d0f3..587dc43bdf 100644 --- a/web/app/components/workflow/panel/version-history-panel/filter/filter-switch.tsx +++ b/web/app/components/workflow/panel/version-history-panel/filter/filter-switch.tsx @@ -1,7 +1,7 @@ import type { FC } from 'react' +import { Switch } from '@langgenius/dify-ui/switch' import * as React from 'react' import { useTranslation } from 'react-i18next' -import Switch from '@/app/components/base/switch' type FilterSwitchProps = { enabled: boolean diff --git a/web/app/components/workflow/panel/version-history-panel/index.tsx b/web/app/components/workflow/panel/version-history-panel/index.tsx index 10dd0e794a..eb1f5c962e 100644 --- a/web/app/components/workflow/panel/version-history-panel/index.tsx +++ b/web/app/components/workflow/panel/version-history-panel/index.tsx @@ -1,5 +1,6 @@ 'use client' import type { VersionHistory } from '@/types/workflow' +import { toast } from '@langgenius/dify-ui/toast' import { RiArrowDownDoubleLine, RiCloseLine, RiLoader2Line } from '@remixicon/react' import copy from 'copy-to-clipboard' import * as React from 'react' @@ -7,7 +8,6 @@ import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import VersionInfoModal from '@/app/components/app/app-publisher/version-info-modal' import Divider from '@/app/components/base/divider' -import { toast } from '@/app/components/base/ui/toast' import { useSelector as useAppContextSelector } from '@/context/app-context' import { useDeleteWorkflow, useInvalidAllLastRun, useResetWorkflowVersionHistory, useRestoreWorkflow, useUpdateWorkflow, useWorkflowVersionHistory } from '@/service/use-workflow' import { useDSL, useWorkflowRefreshDraft, useWorkflowRun } from '../../hooks' diff --git a/web/app/components/workflow/panel/version-history-panel/restore-confirm-modal.tsx b/web/app/components/workflow/panel/version-history-panel/restore-confirm-modal.tsx index 695e6fe35c..9fc2c25742 100644 --- a/web/app/components/workflow/panel/version-history-panel/restore-confirm-modal.tsx +++ b/web/app/components/workflow/panel/version-history-panel/restore-confirm-modal.tsx @@ -1,9 +1,9 @@ import type { FC } from 'react' import type { VersionHistory } from '@/types/workflow' +import { Button } from '@langgenius/dify-ui/button' import * as React from 'react' import { useTranslation } from 'react-i18next' import Modal from '@/app/components/base/modal' -import { Button } from '@/app/components/base/ui/button' type RestoreConfirmModalProps = { isOpen: boolean diff --git a/web/app/components/workflow/panel/workflow-preview.tsx b/web/app/components/workflow/panel/workflow-preview.tsx index e28c494b20..89ae09c374 100644 --- a/web/app/components/workflow/panel/workflow-preview.tsx +++ b/web/app/components/workflow/panel/workflow-preview.tsx @@ -1,4 +1,6 @@ +import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' +import { toast } from '@langgenius/dify-ui/toast' import copy from 'copy-to-clipboard' import { memo, @@ -8,8 +10,6 @@ import { } from 'react' import { useTranslation } from 'react-i18next' import Loading from '@/app/components/base/loading' -import { Button } from '@/app/components/base/ui/button' -import { toast } from '@/app/components/base/ui/toast' import { submitHumanInputForm } from '@/service/workflow' import { useWorkflowInteractions, diff --git a/web/app/components/workflow/run/__tests__/index.spec.tsx b/web/app/components/workflow/run/__tests__/index.spec.tsx index 87937dfb4f..39f26df97e 100644 --- a/web/app/components/workflow/run/__tests__/index.spec.tsx +++ b/web/app/components/workflow/run/__tests__/index.spec.tsx @@ -22,7 +22,7 @@ vi.mock('@/service/log', () => ({ fetchTracingList: (...args: unknown[]) => mockFetchTracingList(...args), })) -vi.mock('@/app/components/base/ui/toast', () => ({ +vi.mock('@langgenius/dify-ui/toast', () => ({ toast: { error: (...args: unknown[]) => mockToastError(...args), }, diff --git a/web/app/components/workflow/run/agent-log/__tests__/agent-log-nav-more.spec.tsx b/web/app/components/workflow/run/agent-log/__tests__/agent-log-nav-more.spec.tsx index 35b32a5ce6..e283740613 100644 --- a/web/app/components/workflow/run/agent-log/__tests__/agent-log-nav-more.spec.tsx +++ b/web/app/components/workflow/run/agent-log/__tests__/agent-log-nav-more.spec.tsx @@ -3,7 +3,7 @@ import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import AgentLogNavMore from '../agent-log-nav-more' -vi.mock('@/app/components/base/ui/dropdown-menu', async () => { +vi.mock('@langgenius/dify-ui/dropdown-menu', async () => { const React = await import('react') const DropdownMenuContext = React.createContext<{ open: boolean, setOpen: (open: boolean) => void } | null>(null) diff --git a/web/app/components/workflow/run/agent-log/agent-log-item.tsx b/web/app/components/workflow/run/agent-log/agent-log-item.tsx index e1714c5f35..67da9c7aa9 100644 --- a/web/app/components/workflow/run/agent-log/agent-log-item.tsx +++ b/web/app/components/workflow/run/agent-log/agent-log-item.tsx @@ -1,4 +1,5 @@ import type { AgentLogItemWithChildren } from '@/types/workflow' +import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { RiArrowRightSLine, @@ -8,7 +9,6 @@ import { useMemo, useState, } from 'react' -import { Button } from '@/app/components/base/ui/button' import useGetIcon from '@/app/components/plugins/install-plugin/base/use-get-icon' import BlockIcon from '@/app/components/workflow/block-icon' import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor' diff --git a/web/app/components/workflow/run/agent-log/agent-log-nav-more.tsx b/web/app/components/workflow/run/agent-log/agent-log-nav-more.tsx index 77f3c778a6..5c8d100012 100644 --- a/web/app/components/workflow/run/agent-log/agent-log-nav-more.tsx +++ b/web/app/components/workflow/run/agent-log/agent-log-nav-more.tsx @@ -1,13 +1,13 @@ import type { AgentLogItemWithChildren } from '@/types/workflow' -import { RiMoreLine } from '@remixicon/react' -import { useState } from 'react' -import { Button } from '@/app/components/base/ui/button' +import { Button } from '@langgenius/dify-ui/button' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, -} from '@/app/components/base/ui/dropdown-menu' +} from '@langgenius/dify-ui/dropdown-menu' +import { RiMoreLine } from '@remixicon/react' +import { useState } from 'react' type AgentLogNavMoreProps = { options: AgentLogItemWithChildren[] diff --git a/web/app/components/workflow/run/agent-log/agent-log-nav.tsx b/web/app/components/workflow/run/agent-log/agent-log-nav.tsx index 2548256cb1..7996595186 100644 --- a/web/app/components/workflow/run/agent-log/agent-log-nav.tsx +++ b/web/app/components/workflow/run/agent-log/agent-log-nav.tsx @@ -1,7 +1,7 @@ import type { AgentLogItemWithChildren } from '@/types/workflow' +import { Button } from '@langgenius/dify-ui/button' import { RiArrowLeftLine } from '@remixicon/react' import { useTranslation } from 'react-i18next' -import { Button } from '@/app/components/base/ui/button' import AgentLogNavMore from './agent-log-nav-more' type AgentLogNavProps = { diff --git a/web/app/components/workflow/run/index.tsx b/web/app/components/workflow/run/index.tsx index bd71b662f5..d066690120 100644 --- a/web/app/components/workflow/run/index.tsx +++ b/web/app/components/workflow/run/index.tsx @@ -3,10 +3,10 @@ import type { FC } from 'react' import type { WorkflowRunDetailResponse } from '@/models/log' import type { NodeTracing } from '@/types/workflow' import { cn } from '@langgenius/dify-ui/cn' +import { toast } from '@langgenius/dify-ui/toast' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import Loading from '@/app/components/base/loading' -import { toast } from '@/app/components/base/ui/toast' import { WorkflowRunningStatus } from '@/app/components/workflow/types' import { fetchRunDetail, fetchTracingList } from '@/service/log' import { useStore } from '../store' diff --git a/web/app/components/workflow/run/iteration-log/iteration-log-trigger.tsx b/web/app/components/workflow/run/iteration-log/iteration-log-trigger.tsx index 77b9425501..8fb857f344 100644 --- a/web/app/components/workflow/run/iteration-log/iteration-log-trigger.tsx +++ b/web/app/components/workflow/run/iteration-log/iteration-log-trigger.tsx @@ -2,10 +2,10 @@ import type { IterationDurationMap, NodeTracing, } from '@/types/workflow' +import { Button } from '@langgenius/dify-ui/button' import { RiArrowRightSLine } from '@remixicon/react' import { useTranslation } from 'react-i18next' import { Iteration } from '@/app/components/base/icons/src/vender/workflow' -import { Button } from '@/app/components/base/ui/button' import { NodeRunningStatus } from '@/app/components/workflow/types' type IterationLogTriggerProps = { diff --git a/web/app/components/workflow/run/loop-log/loop-log-trigger.tsx b/web/app/components/workflow/run/loop-log/loop-log-trigger.tsx index 42593e564f..277db8cef4 100644 --- a/web/app/components/workflow/run/loop-log/loop-log-trigger.tsx +++ b/web/app/components/workflow/run/loop-log/loop-log-trigger.tsx @@ -3,10 +3,10 @@ import type { LoopVariableMap, NodeTracing, } from '@/types/workflow' +import { Button } from '@langgenius/dify-ui/button' import { RiArrowRightSLine } from '@remixicon/react' import { useTranslation } from 'react-i18next' import { Loop } from '@/app/components/base/icons/src/vender/workflow' -import { Button } from '@/app/components/base/ui/button' type LoopLogTriggerProps = { nodeInfo: NodeTracing diff --git a/web/app/components/workflow/run/retry-log/retry-log-trigger.tsx b/web/app/components/workflow/run/retry-log/retry-log-trigger.tsx index 592b7775f1..84c7808df1 100644 --- a/web/app/components/workflow/run/retry-log/retry-log-trigger.tsx +++ b/web/app/components/workflow/run/retry-log/retry-log-trigger.tsx @@ -1,10 +1,10 @@ import type { NodeTracing } from '@/types/workflow' +import { Button } from '@langgenius/dify-ui/button' import { RiArrowRightSLine, RiRestartFill, } from '@remixicon/react' import { useTranslation } from 'react-i18next' -import { Button } from '@/app/components/base/ui/button' type RetryLogTriggerProps = { nodeInfo: NodeTracing diff --git a/web/app/components/workflow/selection-contextmenu.tsx b/web/app/components/workflow/selection-contextmenu.tsx index c82f431ddf..26b5429df4 100644 --- a/web/app/components/workflow/selection-contextmenu.tsx +++ b/web/app/components/workflow/selection-contextmenu.tsx @@ -1,4 +1,12 @@ import type { Node } from './types' +import { + ContextMenu, + ContextMenuContent, + ContextMenuGroup, + ContextMenuItem, + ContextMenuLabel, + ContextMenuSeparator, +} from '@langgenius/dify-ui/context-menu' import { produce } from 'immer' import { memo, @@ -8,14 +16,6 @@ import { } from 'react' import { useTranslation } from 'react-i18next' import { useStore as useReactFlowStore } from 'reactflow' -import { - ContextMenu, - ContextMenuContent, - ContextMenuGroup, - ContextMenuItem, - ContextMenuLabel, - ContextMenuSeparator, -} from '@/app/components/base/ui/context-menu' import { useCollaborativeWorkflow } from '@/app/components/workflow/hooks/use-collaborative-workflow' import { useNodesInteractions, useNodesReadOnly, useNodesSyncDraft } from './hooks' import { useSelectionInteractions } from './hooks/use-selection-interactions' diff --git a/web/app/components/workflow/update-dsl-modal.tsx b/web/app/components/workflow/update-dsl-modal.tsx index 04d687ce73..cfa9c995eb 100644 --- a/web/app/components/workflow/update-dsl-modal.tsx +++ b/web/app/components/workflow/update-dsl-modal.tsx @@ -1,6 +1,8 @@ 'use client' import type { MouseEventHandler } from 'react' +import { Button } from '@langgenius/dify-ui/button' +import { toast } from '@langgenius/dify-ui/toast' import { RiAlertFill, RiCloseLine, @@ -16,8 +18,6 @@ import { useTranslation } from 'react-i18next' import Uploader from '@/app/components/app/create-from-dsl-modal/uploader' import { useStore as useAppStore } from '@/app/components/app/store' import Modal from '@/app/components/base/modal' -import { Button } from '@/app/components/base/ui/button' -import { toast } from '@/app/components/base/ui/toast' import { usePluginDependencies } from '@/app/components/workflow/plugin-dependency/hooks' import { useEventEmitterContextContext } from '@/context/event-emitter' import { diff --git a/web/app/components/workflow/variable-inspect/__tests__/value-content-sections.spec.tsx b/web/app/components/workflow/variable-inspect/__tests__/value-content-sections.spec.tsx index 2ebe527104..bba17a7c32 100644 --- a/web/app/components/workflow/variable-inspect/__tests__/value-content-sections.spec.tsx +++ b/web/app/components/workflow/variable-inspect/__tests__/value-content-sections.spec.tsx @@ -18,7 +18,7 @@ const toastMocks = vi.hoisted(() => ({ promise: vi.fn(), })) -vi.mock('@/app/components/base/ui/toast', () => ({ +vi.mock('@langgenius/dify-ui/toast', () => ({ toast: Object.assign(toastMocks.call, { success: vi.fn(), error: vi.fn(), diff --git a/web/app/components/workflow/variable-inspect/group.tsx b/web/app/components/workflow/variable-inspect/group.tsx index 7d94226786..24786e4b1f 100644 --- a/web/app/components/workflow/variable-inspect/group.tsx +++ b/web/app/components/workflow/variable-inspect/group.tsx @@ -10,7 +10,7 @@ import { } from '@remixicon/react' import { useState } from 'react' import { useTranslation } from 'react-i18next' -// import { Button } from '@/app/components/base/ui/button' +// import { Button } from '@langgenius/dify-ui/button' import ActionButton from '@/app/components/base/action-button' import Tooltip from '@/app/components/base/tooltip' import BlockIcon from '@/app/components/workflow/block-icon' diff --git a/web/app/components/workflow/variable-inspect/left.tsx b/web/app/components/workflow/variable-inspect/left.tsx index f3450cea13..6d590eb281 100644 --- a/web/app/components/workflow/variable-inspect/left.tsx +++ b/web/app/components/workflow/variable-inspect/left.tsx @@ -1,10 +1,10 @@ import type { currentVarType } from './panel' import type { VarInInspect } from '@/types/workflow' +import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' // import { useState } from 'react' import { useTranslation } from 'react-i18next' -import { Button } from '@/app/components/base/ui/button' import { VarInInspectType } from '@/types/workflow' import useCurrentVars from '../hooks/use-inspect-vars-crud' import { useNodesInteractions } from '../hooks/use-nodes-interactions' diff --git a/web/app/components/workflow/variable-inspect/listening.tsx b/web/app/components/workflow/variable-inspect/listening.tsx index 4559665ffa..3994355d58 100644 --- a/web/app/components/workflow/variable-inspect/listening.tsx +++ b/web/app/components/workflow/variable-inspect/listening.tsx @@ -3,13 +3,13 @@ import type { FC } from 'react' import type { Node } from 'reactflow' import type { ScheduleTriggerNodeType } from '@/app/components/workflow/nodes/trigger-schedule/types' import type { WebhookTriggerNodeType } from '@/app/components/workflow/nodes/trigger-webhook/types' +import { Button } from '@langgenius/dify-ui/button' import copy from 'copy-to-clipboard' import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { useStoreApi } from 'reactflow' import { StopCircle } from '@/app/components/base/icons/src/vender/line/mediaAndDevices' import Tooltip from '@/app/components/base/tooltip' -import { Button } from '@/app/components/base/ui/button' import BlockIcon from '@/app/components/workflow/block-icon' import { useGetToolIcon } from '@/app/components/workflow/hooks/use-tool-icon' import { getNextExecutionTime } from '@/app/components/workflow/nodes/trigger-schedule/utils/execution-time-calculator' diff --git a/web/app/components/workflow/workflow-preview/components/zoom-in-out.tsx b/web/app/components/workflow/workflow-preview/components/zoom-in-out.tsx index 3f714c5ca9..9ea32caec3 100644 --- a/web/app/components/workflow/workflow-preview/components/zoom-in-out.tsx +++ b/web/app/components/workflow/workflow-preview/components/zoom-in-out.tsx @@ -1,5 +1,12 @@ import type { FC } from 'react' import { cn } from '@langgenius/dify-ui/cn' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@langgenius/dify-ui/dropdown-menu' import { Fragment, memo, @@ -10,13 +17,6 @@ import { useReactFlow, useViewport, } from 'reactflow' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from '@/app/components/base/ui/dropdown-menu' import TipPopup from '@/app/components/workflow/operator/tip-popup' import ShortcutsName from '@/app/components/workflow/shortcuts-name' diff --git a/web/app/education-apply/education-apply-page.tsx b/web/app/education-apply/education-apply-page.tsx index 7fda139aa9..93259c1ccb 100644 --- a/web/app/education-apply/education-apply-page.tsx +++ b/web/app/education-apply/education-apply-page.tsx @@ -1,5 +1,7 @@ 'use client' +import { Button } from '@langgenius/dify-ui/button' +import { toast } from '@langgenius/dify-ui/toast' import { RiExternalLinkLine } from '@remixicon/react' import { noop } from 'es-toolkit/function' import { @@ -7,8 +9,6 @@ import { } from 'react' import { useTranslation } from 'react-i18next' import Checkbox from '@/app/components/base/checkbox' -import { Button } from '@/app/components/base/ui/button' -import { toast } from '@/app/components/base/ui/toast' import { EDUCATION_VERIFYING_LOCALSTORAGE_ITEM } from '@/app/education-apply/constants' import { useDocLink } from '@/context/i18n' import { useProviderContext } from '@/context/provider-context' diff --git a/web/app/education-apply/expire-notice-modal.tsx b/web/app/education-apply/expire-notice-modal.tsx index ec88bba1b5..e54afaecde 100644 --- a/web/app/education-apply/expire-notice-modal.tsx +++ b/web/app/education-apply/expire-notice-modal.tsx @@ -1,9 +1,9 @@ 'use client' +import { Button } from '@langgenius/dify-ui/button' import { RiExternalLinkLine } from '@remixicon/react' import * as React from 'react' import { useTranslation } from 'react-i18next' import Modal from '@/app/components/base/modal' -import { Button } from '@/app/components/base/ui/button' import { useDocLink } from '@/context/i18n' import { useModalContextSelector } from '@/context/modal-context' import useTimestamp from '@/hooks/use-timestamp' diff --git a/web/app/education-apply/user-info.tsx b/web/app/education-apply/user-info.tsx index 32d56b2780..be9b319038 100644 --- a/web/app/education-apply/user-info.tsx +++ b/web/app/education-apply/user-info.tsx @@ -1,7 +1,7 @@ +import { Avatar } from '@langgenius/dify-ui/avatar' +import { Button } from '@langgenius/dify-ui/button' import { useTranslation } from 'react-i18next' import { Triangle } from '@/app/components/base/icons/src/public/education' -import { Avatar } from '@/app/components/base/ui/avatar' -import { Button } from '@/app/components/base/ui/button' import { useAppContext } from '@/context/app-context' import { useRouter } from '@/next/navigation' import { useLogout } from '@/service/use-common' diff --git a/web/app/education-apply/verify-state-modal.tsx b/web/app/education-apply/verify-state-modal.tsx index c4d836f178..bb7439352e 100644 --- a/web/app/education-apply/verify-state-modal.tsx +++ b/web/app/education-apply/verify-state-modal.tsx @@ -1,3 +1,4 @@ +import { Button } from '@langgenius/dify-ui/button' import { RiExternalLinkLine, } from '@remixicon/react' @@ -5,7 +6,6 @@ import * as React from 'react' import { useEffect, useRef, useState } from 'react' import { createPortal } from 'react-dom' import { useTranslation } from 'react-i18next' -import { Button } from '@/app/components/base/ui/button' import { useDocLink } from '@/context/i18n' type IConfirm = { diff --git a/web/app/forgot-password/ChangePasswordForm.tsx b/web/app/forgot-password/ChangePasswordForm.tsx index bf262181a9..deb520c436 100644 --- a/web/app/forgot-password/ChangePasswordForm.tsx +++ b/web/app/forgot-password/ChangePasswordForm.tsx @@ -1,11 +1,11 @@ 'use client' import { CheckCircleIcon } from '@heroicons/react/24/solid' +import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' +import { toast } from '@langgenius/dify-ui/toast' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import Loading from '@/app/components/base/loading' -import { Button } from '@/app/components/base/ui/button' -import { toast } from '@/app/components/base/ui/toast' import { validPassword } from '@/config' import { useSearchParams } from '@/next/navigation' import { changePasswordWithToken } from '@/service/common' diff --git a/web/app/forgot-password/ForgotPasswordForm.tsx b/web/app/forgot-password/ForgotPasswordForm.tsx index 7ce5e7d8dd..b4fba60182 100644 --- a/web/app/forgot-password/ForgotPasswordForm.tsx +++ b/web/app/forgot-password/ForgotPasswordForm.tsx @@ -1,15 +1,15 @@ 'use client' import type { InitValidateStatusResponse } from '@/models/common' +import { Button } from '@langgenius/dify-ui/button' + import { useStore } from '@tanstack/react-form' import * as React from 'react' - import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import * as z from 'zod' import { formContext, useAppForm } from '@/app/components/base/form' import { zodSubmitValidator } from '@/app/components/base/form/utils/zod-submit-validator' -import { Button } from '@/app/components/base/ui/button' import { useRouter } from '@/next/navigation' import { fetchInitValidateStatus, diff --git a/web/app/init/InitPasswordPopup.tsx b/web/app/init/InitPasswordPopup.tsx index b0c74978fa..6b25fa371a 100644 --- a/web/app/init/InitPasswordPopup.tsx +++ b/web/app/init/InitPasswordPopup.tsx @@ -1,9 +1,9 @@ 'use client' import type { InitValidateStatusResponse } from '@/models/common' +import { Button } from '@langgenius/dify-ui/button' +import { toast } from '@langgenius/dify-ui/toast' import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' -import { Button } from '@/app/components/base/ui/button' -import { toast } from '@/app/components/base/ui/toast' import useDocumentTitle from '@/hooks/use-document-title' import { useRouter } from '@/next/navigation' import { fetchInitValidateStatus, initValidate } from '@/service/common' diff --git a/web/app/install/installForm.tsx b/web/app/install/installForm.tsx index c068548561..d9b6e0c7ad 100644 --- a/web/app/install/installForm.tsx +++ b/web/app/install/installForm.tsx @@ -1,8 +1,9 @@ 'use client' import type { InitValidateStatusResponse, SetupStatusResponse } from '@/models/common' +import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' -import { useStore } from '@tanstack/react-form' +import { useStore } from '@tanstack/react-form' import * as React from 'react' import { useEffect } from 'react' import { useTranslation } from 'react-i18next' @@ -10,7 +11,6 @@ import * as z from 'zod' import { formContext, useAppForm } from '@/app/components/base/form' import { zodSubmitValidator } from '@/app/components/base/form/utils/zod-submit-validator' import Input from '@/app/components/base/input' -import { Button } from '@/app/components/base/ui/button' import { validPassword } from '@/config' import { LICENSE_LINK } from '@/constants/link' diff --git a/web/app/layout.tsx b/web/app/layout.tsx index 63e506bd45..be0c854f02 100644 --- a/web/app/layout.tsx +++ b/web/app/layout.tsx @@ -1,4 +1,6 @@ import type { Viewport } from '@/next' +import { ToastHost } from '@langgenius/dify-ui/toast' +import { TooltipProvider } from '@langgenius/dify-ui/tooltip' import { Provider as JotaiProvider } from 'jotai/react' import { ThemeProvider } from 'next-themes' import { NuqsAdapter } from 'nuqs/adapters/next/app' @@ -6,8 +8,6 @@ import GlobalPublicStoreProvider from '@/context/global-public-context' import { TanstackQueryInitializer } from '@/context/query-client' import { getDatasetMap } from '@/env' import { getLocaleOnServer } from '@/i18n-config/server' -import { ToastHost } from './components/base/ui/toast' -import { TooltipProvider } from './components/base/ui/tooltip' import PartnerStackCookieRecorder from './components/billing/partner-stack/cookie-recorder' import CreateAppAttributionBootstrap from './components/create-app-attribution-bootstrap' import { AgentationLoader } from './components/devtools/agentation-loader' diff --git a/web/app/reset-password/check-code/page.tsx b/web/app/reset-password/check-code/page.tsx index 98ea828aae..b11935481e 100644 --- a/web/app/reset-password/check-code/page.tsx +++ b/web/app/reset-password/check-code/page.tsx @@ -1,10 +1,10 @@ 'use client' +import { Button } from '@langgenius/dify-ui/button' +import { toast } from '@langgenius/dify-ui/toast' import { RiArrowLeftLine, RiMailSendFill } from '@remixicon/react' import { useState } from 'react' import { useTranslation } from 'react-i18next' import Input from '@/app/components/base/input' -import { Button } from '@/app/components/base/ui/button' -import { toast } from '@/app/components/base/ui/toast' import Countdown from '@/app/components/signin/countdown' import { useLocale } from '@/context/i18n' import { useRouter, useSearchParams } from '@/next/navigation' diff --git a/web/app/reset-password/page.tsx b/web/app/reset-password/page.tsx index d58fd7e9ff..63b950bd11 100644 --- a/web/app/reset-password/page.tsx +++ b/web/app/reset-password/page.tsx @@ -1,11 +1,11 @@ 'use client' +import { Button } from '@langgenius/dify-ui/button' +import { toast } from '@langgenius/dify-ui/toast' import { RiArrowLeftLine, RiLockPasswordLine } from '@remixicon/react' import { noop } from 'es-toolkit/function' import { useState } from 'react' import { useTranslation } from 'react-i18next' import Input from '@/app/components/base/input' -import { Button } from '@/app/components/base/ui/button' -import { toast } from '@/app/components/base/ui/toast' import { emailRegex } from '@/config' import { useLocale } from '@/context/i18n' import useDocumentTitle from '@/hooks/use-document-title' diff --git a/web/app/reset-password/set-password/page.tsx b/web/app/reset-password/set-password/page.tsx index 7277b6d30c..4a8cb2af9b 100644 --- a/web/app/reset-password/set-password/page.tsx +++ b/web/app/reset-password/set-password/page.tsx @@ -1,12 +1,12 @@ 'use client' +import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' +import { toast } from '@langgenius/dify-ui/toast' import { RiCheckboxCircleFill } from '@remixicon/react' import { useCountDown } from 'ahooks' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import Input from '@/app/components/base/input' -import { Button } from '@/app/components/base/ui/button' -import { toast } from '@/app/components/base/ui/toast' import { validPassword } from '@/config' import { useRouter, useSearchParams } from '@/next/navigation' import { changePasswordWithToken } from '@/service/common' diff --git a/web/app/signin/check-code/page.tsx b/web/app/signin/check-code/page.tsx index b86c984f31..fb52e0b5b7 100644 --- a/web/app/signin/check-code/page.tsx +++ b/web/app/signin/check-code/page.tsx @@ -1,12 +1,12 @@ 'use client' import type { FormEvent } from 'react' +import { Button } from '@langgenius/dify-ui/button' +import { toast } from '@langgenius/dify-ui/toast' import { RiArrowLeftLine, RiMailSendFill } from '@remixicon/react' import { useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { trackEvent } from '@/app/components/base/amplitude' import Input from '@/app/components/base/input' -import { Button } from '@/app/components/base/ui/button' -import { toast } from '@/app/components/base/ui/toast' import Countdown from '@/app/components/signin/countdown' import { useLocale } from '@/context/i18n' diff --git a/web/app/signin/components/mail-and-code-auth.tsx b/web/app/signin/components/mail-and-code-auth.tsx index 5c5431c42a..41cf50854e 100644 --- a/web/app/signin/components/mail-and-code-auth.tsx +++ b/web/app/signin/components/mail-and-code-auth.tsx @@ -1,9 +1,9 @@ import type { FormEvent } from 'react' +import { Button } from '@langgenius/dify-ui/button' +import { toast } from '@langgenius/dify-ui/toast' import { useState } from 'react' import { useTranslation } from 'react-i18next' import Input from '@/app/components/base/input' -import { Button } from '@/app/components/base/ui/button' -import { toast } from '@/app/components/base/ui/toast' import { COUNT_DOWN_KEY, COUNT_DOWN_TIME_MS } from '@/app/components/signin/countdown' import { emailRegex } from '@/config' import { useLocale } from '@/context/i18n' diff --git a/web/app/signin/components/mail-and-password-auth.tsx b/web/app/signin/components/mail-and-password-auth.tsx index ca93b9b391..6feaf11426 100644 --- a/web/app/signin/components/mail-and-password-auth.tsx +++ b/web/app/signin/components/mail-and-password-auth.tsx @@ -1,11 +1,11 @@ import type { ResponseError } from '@/service/fetch' +import { Button } from '@langgenius/dify-ui/button' +import { toast } from '@langgenius/dify-ui/toast' import { noop } from 'es-toolkit/function' import { useState } from 'react' import { useTranslation } from 'react-i18next' import { trackEvent } from '@/app/components/base/amplitude' import Input from '@/app/components/base/input' -import { Button } from '@/app/components/base/ui/button' -import { toast } from '@/app/components/base/ui/toast' import { emailRegex } from '@/config' import { useLocale } from '@/context/i18n' import Link from '@/next/link' diff --git a/web/app/signin/components/social-auth.tsx b/web/app/signin/components/social-auth.tsx index 6eb9d62703..09fa528d1f 100644 --- a/web/app/signin/components/social-auth.tsx +++ b/web/app/signin/components/social-auth.tsx @@ -1,6 +1,6 @@ +import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { useTranslation } from 'react-i18next' -import { Button } from '@/app/components/base/ui/button' import { API_PREFIX } from '@/config' import { useSearchParams } from '@/next/navigation' import { getPurifyHref } from '@/utils' diff --git a/web/app/signin/components/sso-auth.tsx b/web/app/signin/components/sso-auth.tsx index 5ead876741..32612e4edd 100644 --- a/web/app/signin/components/sso-auth.tsx +++ b/web/app/signin/components/sso-auth.tsx @@ -1,10 +1,10 @@ 'use client' import type { FC } from 'react' +import { Button } from '@langgenius/dify-ui/button' +import { toast } from '@langgenius/dify-ui/toast' import { useState } from 'react' import { useTranslation } from 'react-i18next' import { Lock01 } from '@/app/components/base/icons/src/vender/solid/security' -import { Button } from '@/app/components/base/ui/button' -import { toast } from '@/app/components/base/ui/toast' import { useRouter, useSearchParams } from '@/next/navigation' import { getUserOAuth2SSOUrl, getUserOIDCSSOUrl, getUserSAMLSSOUrl } from '@/service/sso' import { SSOProtocol } from '@/types/feature' diff --git a/web/app/signin/invite-settings/page.tsx b/web/app/signin/invite-settings/page.tsx index 91973f07f9..fe3dac7153 100644 --- a/web/app/signin/invite-settings/page.tsx +++ b/web/app/signin/invite-settings/page.tsx @@ -1,5 +1,7 @@ 'use client' import type { Locale } from '@/i18n-config' +import { Button } from '@langgenius/dify-ui/button' +import { toast } from '@langgenius/dify-ui/toast' import { RiAccountCircleLine } from '@remixicon/react' import { noop } from 'es-toolkit/function' import { useCallback, useState } from 'react' @@ -7,8 +9,6 @@ import { useTranslation } from 'react-i18next' import Input from '@/app/components/base/input' import Loading from '@/app/components/base/loading' import { SimpleSelect } from '@/app/components/base/select' -import { Button } from '@/app/components/base/ui/button' -import { toast } from '@/app/components/base/ui/toast' import { LICENSE_LINK } from '@/constants/link' import { useGlobalPublicStore } from '@/context/global-public-context' import { setLocaleOnClient } from '@/i18n-config' diff --git a/web/app/signin/normal-form.tsx b/web/app/signin/normal-form.tsx index 4fd0eabf66..2ead90f068 100644 --- a/web/app/signin/normal-form.tsx +++ b/web/app/signin/normal-form.tsx @@ -1,9 +1,9 @@ import { cn } from '@langgenius/dify-ui/cn' +import { toast } from '@langgenius/dify-ui/toast' import { RiContractLine, RiDoorLockLine, RiErrorWarningFill } from '@remixicon/react' import * as React from 'react' import { useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' -import { toast } from '@/app/components/base/ui/toast' import { IS_CE_EDITION } from '@/config' import { useGlobalPublicStore } from '@/context/global-public-context' import Link from '@/next/link' diff --git a/web/app/signin/one-more-step.tsx b/web/app/signin/one-more-step.tsx index 6368533e2a..1f615d5e5a 100644 --- a/web/app/signin/one-more-step.tsx +++ b/web/app/signin/one-more-step.tsx @@ -1,11 +1,11 @@ 'use client' import type { Reducer } from 'react' +import { Button } from '@langgenius/dify-ui/button' +import { toast } from '@langgenius/dify-ui/toast' import { useReducer } from 'react' import { useTranslation } from 'react-i18next' import { SimpleSelect } from '@/app/components/base/select' import Tooltip from '@/app/components/base/tooltip' -import { Button } from '@/app/components/base/ui/button' -import { toast } from '@/app/components/base/ui/toast' import { LICENSE_LINK } from '@/constants/link' import { languages, LanguagesSupported } from '@/i18n-config/language' import Link from '@/next/link' diff --git a/web/app/signup/check-code/page.tsx b/web/app/signup/check-code/page.tsx index a71d5289fe..a6a8cadc13 100644 --- a/web/app/signup/check-code/page.tsx +++ b/web/app/signup/check-code/page.tsx @@ -1,11 +1,11 @@ 'use client' import type { MailSendResponse, MailValidityResponse } from '@/service/use-common' +import { Button } from '@langgenius/dify-ui/button' +import { toast } from '@langgenius/dify-ui/toast' import { RiArrowLeftLine, RiMailSendFill } from '@remixicon/react' import { useState } from 'react' import { useTranslation } from 'react-i18next' import Input from '@/app/components/base/input' -import { Button } from '@/app/components/base/ui/button' -import { toast } from '@/app/components/base/ui/toast' import Countdown from '@/app/components/signin/countdown' import { useLocale } from '@/context/i18n' import { useRouter, useSearchParams } from '@/next/navigation' diff --git a/web/app/signup/components/input-mail.tsx b/web/app/signup/components/input-mail.tsx index ae7fbb986d..f4c5214c11 100644 --- a/web/app/signup/components/input-mail.tsx +++ b/web/app/signup/components/input-mail.tsx @@ -1,10 +1,10 @@ 'use client' import type { MailSendResponse } from '@/service/use-common' +import { Button } from '@langgenius/dify-ui/button' +import { toast } from '@langgenius/dify-ui/toast' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import Input from '@/app/components/base/input' -import { Button } from '@/app/components/base/ui/button' -import { toast } from '@/app/components/base/ui/toast' import Split from '@/app/signin/split' import { emailRegex } from '@/config' import { useGlobalPublicStore } from '@/context/global-public-context' diff --git a/web/app/signup/set-password/page.tsx b/web/app/signup/set-password/page.tsx index ffecd0bc0c..a8eb883078 100644 --- a/web/app/signup/set-password/page.tsx +++ b/web/app/signup/set-password/page.tsx @@ -1,13 +1,13 @@ 'use client' import type { MailRegisterResponse } from '@/service/use-common' +import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' +import { toast } from '@langgenius/dify-ui/toast' import Cookies from 'js-cookie' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import { trackEvent } from '@/app/components/base/amplitude' import Input from '@/app/components/base/input' -import { Button } from '@/app/components/base/ui/button' -import { toast } from '@/app/components/base/ui/toast' import { validPassword } from '@/config' import { useRouter, useSearchParams } from '@/next/navigation' import { useMailRegister } from '@/service/use-common' diff --git a/web/context/provider-context-provider.tsx b/web/context/provider-context-provider.tsx index 078a954e31..0af0f24b9a 100644 --- a/web/context/provider-context-provider.tsx +++ b/web/context/provider-context-provider.tsx @@ -1,11 +1,11 @@ 'use client' import type { ReactNode } from 'react' +import { toast } from '@langgenius/dify-ui/toast' import { useQueryClient } from '@tanstack/react-query' import dayjs from 'dayjs' import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' -import { toast } from '@/app/components/base/ui/toast' import { setZendeskConversationFields } from '@/app/components/base/zendesk/utils' import { defaultPlan } from '@/app/components/billing/config' import { parseCurrentPlan } from '@/app/components/billing/utils' diff --git a/web/docs/overlay-migration.md b/web/docs/overlay-migration.md index 29ef814fcd..0016f34e12 100644 --- a/web/docs/overlay-migration.md +++ b/web/docs/overlay-migration.md @@ -11,14 +11,14 @@ This document tracks the migration away from legacy overlay APIs. - `@/app/components/base/select` (including `custom` / `pure`) - `@/app/components/base/dialog` - Replacement primitives: - - `@/app/components/base/ui/tooltip` - - `@/app/components/base/ui/dropdown-menu` - - `@/app/components/base/ui/context-menu` - - `@/app/components/base/ui/popover` - - `@/app/components/base/ui/dialog` - - `@/app/components/base/ui/alert-dialog` - - `@/app/components/base/ui/select` - - `@/app/components/base/ui/toast` + - `@langgenius/dify-ui/tooltip` + - `@langgenius/dify-ui/dropdown-menu` + - `@langgenius/dify-ui/context-menu` + - `@langgenius/dify-ui/popover` + - `@langgenius/dify-ui/dialog` + - `@langgenius/dify-ui/alert-dialog` + - `@langgenius/dify-ui/select` + - `@langgenius/dify-ui/toast` - Tracking issue: ## ESLint policy diff --git a/web/eslint.constants.mjs b/web/eslint.constants.mjs index 57a14fc22d..46690035fb 100644 --- a/web/eslint.constants.mjs +++ b/web/eslint.constants.mjs @@ -33,7 +33,7 @@ export const OVERLAY_RESTRICTED_IMPORT_PATTERNS = [ '**/base/tooltip', '**/base/tooltip/index', ], - message: 'Deprecated: use @/app/components/base/ui/tooltip instead. See issue #32767.', + message: 'Deprecated: use @langgenius/dify-ui/tooltip instead. See issue #32767.', }, { group: [ @@ -41,7 +41,7 @@ export const OVERLAY_RESTRICTED_IMPORT_PATTERNS = [ '**/base/modal/index', '**/base/modal/modal', ], - message: 'Deprecated: use @/app/components/base/ui/dialog instead. See issue #32767.', + message: 'Deprecated: use @langgenius/dify-ui/dialog instead. See issue #32767.', }, { group: [ @@ -50,14 +50,14 @@ export const OVERLAY_RESTRICTED_IMPORT_PATTERNS = [ '**/base/select/custom', '**/base/select/pure', ], - message: 'Deprecated: use @/app/components/base/ui/select instead. See issue #32767.', + message: 'Deprecated: use @langgenius/dify-ui/select instead. See issue #32767.', }, { group: [ '**/base/dialog', '**/base/dialog/index', ], - message: 'Deprecated: use @/app/components/base/ui/dialog instead. See issue #32767.', + message: 'Deprecated: use @langgenius/dify-ui/dialog instead. See issue #32767.', }, ] diff --git a/web/hooks/use-import-dsl.ts b/web/hooks/use-import-dsl.ts index 6be67c22ac..1ece2a27f0 100644 --- a/web/hooks/use-import-dsl.ts +++ b/web/hooks/use-import-dsl.ts @@ -3,13 +3,13 @@ import type { DSLImportResponse, } from '@/models/app' import type { AppIconType } from '@/types/app' +import { toast } from '@langgenius/dify-ui/toast' import { useCallback, useRef, useState, } from 'react' import { useTranslation } from 'react-i18next' -import { toast } from '@/app/components/base/ui/toast' import { usePluginDependencies } from '@/app/components/workflow/plugin-dependency/hooks' import { NEED_REFRESH_APP_LIST_KEY } from '@/config' import { useSelector } from '@/context/app-context' diff --git a/web/hooks/use-pay.tsx b/web/hooks/use-pay.tsx index 237913b433..593a33a1d2 100644 --- a/web/hooks/use-pay.tsx +++ b/web/hooks/use-pay.tsx @@ -1,7 +1,5 @@ 'use client' -import { useCallback, useEffect, useState } from 'react' -import { useTranslation } from 'react-i18next' import { AlertDialog, AlertDialogActions, @@ -9,7 +7,9 @@ import { AlertDialogContent, AlertDialogDescription, AlertDialogTitle, -} from '@/app/components/base/ui/alert-dialog' +} from '@langgenius/dify-ui/alert-dialog' +import { useCallback, useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' import { useRouter, useSearchParams } from '@/next/navigation' import { useNotionBinding } from '@/service/use-common' diff --git a/web/service/base.ts b/web/service/base.ts index 2063b5bc37..64d13ef59a 100644 --- a/web/service/base.ts +++ b/web/service/base.ts @@ -27,8 +27,8 @@ import type { WorkflowPausedResponse, WorkflowStartedResponse, } from '@/types/workflow' +import { toast } from '@langgenius/dify-ui/toast' import Cookies from 'js-cookie' -import { toast } from '@/app/components/base/ui/toast' import { API_PREFIX, CSRF_COOKIE_NAME, CSRF_HEADER_NAME, IS_CE_EDITION, PASSPORT_HEADER_NAME, PUBLIC_API_PREFIX, WEB_APP_SHARE_CODE_HEADER_NAME } from '@/config' import { asyncRunSafe } from '@/utils' import { basePath } from '@/utils/var' diff --git a/web/service/fetch.spec.ts b/web/service/fetch.spec.ts index 0c01d32438..632f898e88 100644 --- a/web/service/fetch.spec.ts +++ b/web/service/fetch.spec.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { base } from './fetch' -vi.mock('@/app/components/base/ui/toast', () => ({ +vi.mock('@langgenius/dify-ui/toast', () => ({ toast: { add: vi.fn(), }, diff --git a/web/service/fetch.ts b/web/service/fetch.ts index 82870a8d2e..34bd07160a 100644 --- a/web/service/fetch.ts +++ b/web/service/fetch.ts @@ -1,8 +1,8 @@ import type { AfterResponseHook, BeforeRequestHook, Hooks } from 'ky' import type { IOtherOptions } from './base' +import { toast } from '@langgenius/dify-ui/toast' import Cookies from 'js-cookie' import ky, { HTTPError } from 'ky' -import { toast } from '@/app/components/base/ui/toast' import { API_PREFIX, APP_VERSION, CSRF_COOKIE_NAME, CSRF_HEADER_NAME, IS_MARKETPLACE, MARKETPLACE_API_PREFIX, PASSPORT_HEADER_NAME, PUBLIC_API_PREFIX, WEB_APP_SHARE_CODE_HEADER_NAME } from '@/config' import { getWebAppAccessToken, getWebAppPassport } from './webapp-auth' diff --git a/web/tailwind.config.ts b/web/tailwind.config.ts index 32b889e707..21c0455177 100644 --- a/web/tailwind.config.ts +++ b/web/tailwind.config.ts @@ -6,9 +6,11 @@ const config: Config = { './app/**/*.{js,ts,jsx,tsx}', './components/**/*.{js,ts,jsx,tsx}', './context/**/*.{js,ts,jsx,tsx}', + '../packages/dify-ui/src/**/*.{ts,tsx}', './node_modules/streamdown/dist/*.js', './node_modules/@streamdown/math/dist/*.js', '!./**/*.{spec,test}.{js,ts,jsx,tsx}', + '!../packages/dify-ui/src/**/*.{spec,test}.{ts,tsx}', ], ...commonConfig, } From b9c300d57014c3601c1bccf2f2051ff50e6fe271 Mon Sep 17 00:00:00 2001 From: jerryzai Date: Fri, 17 Apr 2026 04:52:27 -0400 Subject: [PATCH 15/24] =?UTF-8?q?chore(api):=20migrate=20mail=20task=20and?= =?UTF-8?q?=20OAuth=20data=20source=20to=20use=20Session(db=E2=80=A6=20(#3?= =?UTF-8?q?5235)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Asuka Minato Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- api/libs/oauth_data_source.py | 131 +++++++++--------- .../mail_clean_document_notify_task.py | 121 ++++++++-------- 2 files changed, 128 insertions(+), 124 deletions(-) diff --git a/api/libs/oauth_data_source.py b/api/libs/oauth_data_source.py index 9b53918f24..934aacb45b 100644 --- a/api/libs/oauth_data_source.py +++ b/api/libs/oauth_data_source.py @@ -6,8 +6,8 @@ from flask_login import current_user from pydantic import TypeAdapter from sqlalchemy import select +from core.db.session_factory import session_factory from core.helper.http_client_pooling import get_pooled_http_client -from extensions.ext_database import db from libs.datetime_utils import naive_utc_now from models.source import DataSourceOauthBinding @@ -95,27 +95,28 @@ class NotionOAuth(OAuthDataSource): pages=pages, ) # save data source binding - data_source_binding = db.session.scalar( - select(DataSourceOauthBinding).where( - DataSourceOauthBinding.tenant_id == current_user.current_tenant_id, - DataSourceOauthBinding.provider == "notion", - DataSourceOauthBinding.access_token == access_token, + with session_factory.create_session() as session: + data_source_binding = session.scalar( + select(DataSourceOauthBinding).where( + DataSourceOauthBinding.tenant_id == current_user.current_tenant_id, + DataSourceOauthBinding.provider == "notion", + DataSourceOauthBinding.access_token == access_token, + ) ) - ) - if data_source_binding: - data_source_binding.source_info = SOURCE_INFO_STORAGE_ADAPTER.validate_python(source_info) - data_source_binding.disabled = False - data_source_binding.updated_at = naive_utc_now() - db.session.commit() - else: - new_data_source_binding = DataSourceOauthBinding( - tenant_id=current_user.current_tenant_id, - access_token=access_token, - source_info=SOURCE_INFO_STORAGE_ADAPTER.validate_python(source_info), - provider="notion", - ) - db.session.add(new_data_source_binding) - db.session.commit() + if data_source_binding: + data_source_binding.source_info = SOURCE_INFO_STORAGE_ADAPTER.validate_python(source_info) + data_source_binding.disabled = False + data_source_binding.updated_at = naive_utc_now() + session.commit() + else: + new_data_source_binding = DataSourceOauthBinding( + tenant_id=current_user.current_tenant_id, + access_token=access_token, + source_info=SOURCE_INFO_STORAGE_ADAPTER.validate_python(source_info), + provider="notion", + ) + session.add(new_data_source_binding) + session.commit() def save_internal_access_token(self, access_token: str) -> None: workspace_name = self.notion_workspace_name(access_token) @@ -130,55 +131,57 @@ class NotionOAuth(OAuthDataSource): pages=pages, ) # save data source binding - data_source_binding = db.session.scalar( - select(DataSourceOauthBinding).where( - DataSourceOauthBinding.tenant_id == current_user.current_tenant_id, - DataSourceOauthBinding.provider == "notion", - DataSourceOauthBinding.access_token == access_token, + with session_factory.create_session() as session: + data_source_binding = session.scalar( + select(DataSourceOauthBinding).where( + DataSourceOauthBinding.tenant_id == current_user.current_tenant_id, + DataSourceOauthBinding.provider == "notion", + DataSourceOauthBinding.access_token == access_token, + ) ) - ) - if data_source_binding: - data_source_binding.source_info = SOURCE_INFO_STORAGE_ADAPTER.validate_python(source_info) - data_source_binding.disabled = False - data_source_binding.updated_at = naive_utc_now() - db.session.commit() - else: - new_data_source_binding = DataSourceOauthBinding( - tenant_id=current_user.current_tenant_id, - access_token=access_token, - source_info=SOURCE_INFO_STORAGE_ADAPTER.validate_python(source_info), - provider="notion", - ) - db.session.add(new_data_source_binding) - db.session.commit() + if data_source_binding: + data_source_binding.source_info = SOURCE_INFO_STORAGE_ADAPTER.validate_python(source_info) + data_source_binding.disabled = False + data_source_binding.updated_at = naive_utc_now() + session.commit() + else: + new_data_source_binding = DataSourceOauthBinding( + tenant_id=current_user.current_tenant_id, + access_token=access_token, + source_info=SOURCE_INFO_STORAGE_ADAPTER.validate_python(source_info), + provider="notion", + ) + session.add(new_data_source_binding) + session.commit() def sync_data_source(self, binding_id: str) -> None: # save data source binding - data_source_binding = db.session.scalar( - select(DataSourceOauthBinding).where( - DataSourceOauthBinding.tenant_id == current_user.current_tenant_id, - DataSourceOauthBinding.provider == "notion", - DataSourceOauthBinding.id == binding_id, - DataSourceOauthBinding.disabled == False, + with session_factory.create_session() as session: + data_source_binding = session.scalar( + select(DataSourceOauthBinding).where( + DataSourceOauthBinding.tenant_id == current_user.current_tenant_id, + DataSourceOauthBinding.provider == "notion", + DataSourceOauthBinding.id == binding_id, + DataSourceOauthBinding.disabled == False, + ) ) - ) - if data_source_binding: - # get all authorized pages - pages = self.get_authorized_pages(data_source_binding.access_token) - source_info = NOTION_SOURCE_INFO_ADAPTER.validate_python(data_source_binding.source_info) - new_source_info = self._build_source_info( - workspace_name=source_info["workspace_name"], - workspace_icon=source_info["workspace_icon"], - workspace_id=source_info["workspace_id"], - pages=pages, - ) - data_source_binding.source_info = SOURCE_INFO_STORAGE_ADAPTER.validate_python(new_source_info) - data_source_binding.disabled = False - data_source_binding.updated_at = naive_utc_now() - db.session.commit() - else: - raise ValueError("Data source binding not found") + if data_source_binding: + # get all authorized pages + pages = self.get_authorized_pages(data_source_binding.access_token) + source_info = NOTION_SOURCE_INFO_ADAPTER.validate_python(data_source_binding.source_info) + new_source_info = self._build_source_info( + workspace_name=source_info["workspace_name"], + workspace_icon=source_info["workspace_icon"], + workspace_id=source_info["workspace_id"], + pages=pages, + ) + data_source_binding.source_info = SOURCE_INFO_STORAGE_ADAPTER.validate_python(new_source_info) + data_source_binding.disabled = False + data_source_binding.updated_at = naive_utc_now() + session.commit() + else: + raise ValueError("Data source binding not found") def get_authorized_pages(self, access_token: str) -> list[NotionPageSummary]: pages: list[NotionPageSummary] = [] diff --git a/api/schedule/mail_clean_document_notify_task.py b/api/schedule/mail_clean_document_notify_task.py index 8479cdfb0c..2cc0192a4a 100644 --- a/api/schedule/mail_clean_document_notify_task.py +++ b/api/schedule/mail_clean_document_notify_task.py @@ -7,8 +7,8 @@ from sqlalchemy import select import app from configs import dify_config +from core.db.session_factory import session_factory from enums.cloud_plan import CloudPlan -from extensions.ext_database import db from extensions.ext_mail import mail from libs.email_i18n import EmailType, get_email_i18n_service from models import Account, Tenant, TenantAccountJoin @@ -33,67 +33,68 @@ def mail_clean_document_notify_task(): # send document clean notify mail try: - dataset_auto_disable_logs = db.session.scalars( - select(DatasetAutoDisableLog).where(DatasetAutoDisableLog.notified == False) - ).all() - # group by tenant_id - dataset_auto_disable_logs_map: dict[str, list[DatasetAutoDisableLog]] = defaultdict(list) - for dataset_auto_disable_log in dataset_auto_disable_logs: - if dataset_auto_disable_log.tenant_id not in dataset_auto_disable_logs_map: - dataset_auto_disable_logs_map[dataset_auto_disable_log.tenant_id] = [] - dataset_auto_disable_logs_map[dataset_auto_disable_log.tenant_id].append(dataset_auto_disable_log) - url = f"{dify_config.CONSOLE_WEB_URL}/datasets" - for tenant_id, tenant_dataset_auto_disable_logs in dataset_auto_disable_logs_map.items(): - features = FeatureService.get_features(tenant_id) - plan = features.billing.subscription.plan - if plan != CloudPlan.SANDBOX: - knowledge_details = [] - # check tenant - tenant = db.session.scalar(select(Tenant).where(Tenant.id == tenant_id)) - if not tenant: - continue - # check current owner - current_owner_join = db.session.scalar( - select(TenantAccountJoin) - .where(TenantAccountJoin.tenant_id == tenant.id, TenantAccountJoin.role == "owner") - .limit(1) - ) - if not current_owner_join: - continue - account = db.session.scalar(select(Account).where(Account.id == current_owner_join.account_id)) - if not account: - continue + with session_factory.create_session() as session: + dataset_auto_disable_logs = session.scalars( + select(DatasetAutoDisableLog).where(DatasetAutoDisableLog.notified.is_(False)) + ).all() + # group by tenant_id + dataset_auto_disable_logs_map: dict[str, list[DatasetAutoDisableLog]] = defaultdict(list) + for dataset_auto_disable_log in dataset_auto_disable_logs: + if dataset_auto_disable_log.tenant_id not in dataset_auto_disable_logs_map: + dataset_auto_disable_logs_map[dataset_auto_disable_log.tenant_id] = [] + dataset_auto_disable_logs_map[dataset_auto_disable_log.tenant_id].append(dataset_auto_disable_log) + url = f"{dify_config.CONSOLE_WEB_URL}/datasets" + for tenant_id, tenant_dataset_auto_disable_logs in dataset_auto_disable_logs_map.items(): + features = FeatureService.get_features(tenant_id) + plan = features.billing.subscription.plan + if plan != CloudPlan.SANDBOX: + knowledge_details = [] + # check tenant + tenant = session.scalar(select(Tenant).where(Tenant.id == tenant_id)) + if not tenant: + continue + # check current owner + current_owner_join = session.scalar( + select(TenantAccountJoin) + .where(TenantAccountJoin.tenant_id == tenant.id, TenantAccountJoin.role == "owner") + .limit(1) + ) + if not current_owner_join: + continue + account = session.scalar(select(Account).where(Account.id == current_owner_join.account_id)) + if not account: + continue - dataset_auto_dataset_map = {} # type: ignore + dataset_auto_dataset_map = {} # type: ignore + for dataset_auto_disable_log in tenant_dataset_auto_disable_logs: + if dataset_auto_disable_log.dataset_id not in dataset_auto_dataset_map: + dataset_auto_dataset_map[dataset_auto_disable_log.dataset_id] = [] + dataset_auto_dataset_map[dataset_auto_disable_log.dataset_id].append( + dataset_auto_disable_log.document_id + ) + + for dataset_id, document_ids in dataset_auto_dataset_map.items(): + dataset = session.scalar(select(Dataset).where(Dataset.id == dataset_id)) + if dataset: + document_count = len(document_ids) + knowledge_details.append(rf"Knowledge base {dataset.name}: {document_count} documents") + if knowledge_details: + email_service = get_email_i18n_service() + email_service.send_email( + email_type=EmailType.DOCUMENT_CLEAN_NOTIFY, + language_code="en-US", + to=account.email, + template_context={ + "userName": account.email, + "knowledge_details": knowledge_details, + "url": url, + }, + ) + + # update notified to True for dataset_auto_disable_log in tenant_dataset_auto_disable_logs: - if dataset_auto_disable_log.dataset_id not in dataset_auto_dataset_map: - dataset_auto_dataset_map[dataset_auto_disable_log.dataset_id] = [] - dataset_auto_dataset_map[dataset_auto_disable_log.dataset_id].append( - dataset_auto_disable_log.document_id - ) - - for dataset_id, document_ids in dataset_auto_dataset_map.items(): - dataset = db.session.scalar(select(Dataset).where(Dataset.id == dataset_id)) - if dataset: - document_count = len(document_ids) - knowledge_details.append(rf"Knowledge base {dataset.name}: {document_count} documents") - if knowledge_details: - email_service = get_email_i18n_service() - email_service.send_email( - email_type=EmailType.DOCUMENT_CLEAN_NOTIFY, - language_code="en-US", - to=account.email, - template_context={ - "userName": account.email, - "knowledge_details": knowledge_details, - "url": url, - }, - ) - - # update notified to True - for dataset_auto_disable_log in tenant_dataset_auto_disable_logs: - dataset_auto_disable_log.notified = True - db.session.commit() + dataset_auto_disable_log.notified = True + session.commit() end_at = time.perf_counter() logger.info(click.style(f"Send document clean notify mail succeeded: latency: {end_at - start_at}", fg="green")) except Exception: From 96122692cba202fab96d14e72cb4810593dc6669 Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Fri, 17 Apr 2026 17:10:10 +0800 Subject: [PATCH 16/24] fix(web): keep workflow panel operator anchor mounted during menu close animation (#35363) --- .../nodes/_base/components/node-control.tsx | 24 +++++++------------ .../_base/components/panel-operator/index.tsx | 14 +++++------ 2 files changed, 14 insertions(+), 24 deletions(-) diff --git a/web/app/components/workflow/nodes/_base/components/node-control.tsx b/web/app/components/workflow/nodes/_base/components/node-control.tsx index 4f2980186d..439e097bc9 100644 --- a/web/app/components/workflow/nodes/_base/components/node-control.tsx +++ b/web/app/components/workflow/nodes/_base/components/node-control.tsx @@ -1,15 +1,12 @@ import type { FC } from 'react' import type { Node } from '../../../types' +import { cn } from '@langgenius/dify-ui/cn' import { Tooltip, TooltipContent, TooltipTrigger, } from '@langgenius/dify-ui/tooltip' -import { - memo, - useCallback, - useState, -} from 'react' +import { memo } from 'react' import { useTranslation } from 'react-i18next' import { Stop, @@ -31,23 +28,19 @@ const NodeControl: FC = ({ pluginInstallLocked, }) => { const { t } = useTranslation() - const [open, setOpen] = useState(false) const { handleNodeSelect } = useNodesInteractions() const workflowStore = useWorkflowStore() const isSingleRunning = data._singleRunningStatus === NodeRunningStatus.Running - const handleOpenChange = useCallback((newOpen: boolean) => { - setOpen(newOpen) - }, []) const isChildNode = !!(data.isInIteration || data.isInLoop) return ( ) +type BannerImpressionTrackerProps = { + banners: BannerType[] + accountId?: string + language: string + trackedBannerIdsRef: React.MutableRefObject> +} + +const BannerImpressionTracker: FC = ({ + banners, + accountId, + language, + trackedBannerIdsRef, +}) => { + const { selectedIndex } = useCarousel() + + useEffect(() => { + if (!accountId) + return + + const currentBanner = banners[selectedIndex] + if (!currentBanner || trackedBannerIdsRef.current.has(currentBanner.id)) + return + + trackEvent('explore_banner_impression', { + banner_id: currentBanner.id, + title: currentBanner.content.title, + sort: selectedIndex + 1, + link: currentBanner.link, + page: 'explore', + language, + account_id: accountId, + event_time: Date.now(), + }) + trackedBannerIdsRef.current.add(currentBanner.id) + }, [accountId, banners, language, selectedIndex, trackedBannerIdsRef]) + + return null +} + const Banner: FC = () => { const locale = useLocale() const { data: banners, isLoading, isError } = useGetBanners(locale) @@ -60,28 +100,6 @@ const Banner: FC = () => { } }, []) - useEffect(() => { - if (!accountId) - return - - enabledBanners.forEach((banner, index) => { - if (trackedBannerIdsRef.current.has(banner.id)) - return - - trackEvent('explore_banner_impression', { - banner_id: banner.id, - title: banner.content.title, - sort: index + 1, - link: banner.link, - page: 'explore', - language: locale, - account_id: accountId, - event_time: Date.now(), - }) - trackedBannerIdsRef.current.add(banner.id) - }) - }, [accountId, enabledBanners, locale]) - if (isLoading) return @@ -102,6 +120,12 @@ const Banner: FC = () => { onMouseEnter={() => setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} > + {enabledBanners.map((banner, index) => ( From 3c7d6739b5adb274261d3f98f6a011e19ee5bf4e Mon Sep 17 00:00:00 2001 From: Stephen Zhou Date: Fri, 17 Apr 2026 20:32:12 +0800 Subject: [PATCH 20/24] test: browser mode for dify ui (#35365) --- .github/workflows/web-tests.yml | 3 + eslint.config.mjs | 2 +- packages/dify-ui/package.json | 7 +- .../src/alert-dialog/__tests__/index.spec.tsx | 72 +- .../src/avatar/__tests__/index.spec.tsx | 82 +- .../src/button/__tests__/index.spec.tsx | 162 +- .../src/context-menu/__tests__/index.spec.tsx | 117 +- .../src/dialog/__tests__/index.spec.tsx | 40 +- .../dropdown-menu/__tests__/index.spec.tsx | 163 +- .../src/number-field/__tests__/index.spec.tsx | 136 +- .../src/popover/__tests__/index.spec.tsx | 46 +- .../src/scroll-area/__tests__/index.spec.tsx | 302 ++- .../src/select/__tests__/index.spec.tsx | 172 +- .../src/slider/__tests__/index.spec.tsx | 90 +- .../src/switch/__tests__/index.spec.tsx | 201 +- .../src/toast/__tests__/index.spec.tsx | 334 ++- .../src/tooltip/__tests__/index.spec.tsx | 53 +- packages/dify-ui/tests/setup.ts | 44 - packages/dify-ui/tsconfig.json | 8 +- packages/dify-ui/vite.config.ts | 9 +- pnpm-lock.yaml | 1903 +++-------------- pnpm-workspace.yaml | 91 +- 22 files changed, 1259 insertions(+), 2778 deletions(-) delete mode 100644 packages/dify-ui/tests/setup.ts diff --git a/.github/workflows/web-tests.yml b/.github/workflows/web-tests.yml index dcee8863ce..2a5cf19645 100644 --- a/.github/workflows/web-tests.yml +++ b/.github/workflows/web-tests.yml @@ -109,6 +109,9 @@ jobs: - name: Setup web environment uses: ./.github/actions/setup-web + - name: Install Chromium for Browser Mode + run: vp exec playwright install --with-deps chromium + - name: Run dify-ui tests run: vp test run --coverage --silent=passed-only diff --git a/eslint.config.mjs b/eslint.config.mjs index 5e81e95f2f..ae9fdaff01 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -13,6 +13,7 @@ export default antfu( '!e2e/**', '!eslint.config.mjs', '!package.json', + '!pnpm-workspace.yaml', '!vite.config.ts', ...original, ], @@ -35,7 +36,6 @@ export default antfu( }, }, e18e: false, - pnpm: false, }, markdownPreferences.configs.standard, { diff --git a/packages/dify-ui/package.json b/packages/dify-ui/package.json index 2b78b25ed6..b3430ab4ee 100644 --- a/packages/dify-ui/package.json +++ b/packages/dify-ui/package.json @@ -99,15 +99,12 @@ "@storybook/addon-themes": "catalog:", "@storybook/react-vite": "catalog:", "@tailwindcss/vite": "catalog:", - "@testing-library/jest-dom": "catalog:", - "@testing-library/react": "catalog:", - "@testing-library/user-event": "catalog:", "@types/react": "catalog:", "@types/react-dom": "catalog:", "@vitejs/plugin-react": "catalog:", "@vitest/coverage-v8": "catalog:", "class-variance-authority": "catalog:", - "happy-dom": "catalog:", + "playwright": "catalog:", "react": "catalog:", "react-dom": "catalog:", "storybook": "catalog:", @@ -115,6 +112,6 @@ "typescript": "catalog:", "vite": "catalog:", "vite-plus": "catalog:", - "vitest": "catalog:" + "vitest-browser-react": "catalog:" } } diff --git a/packages/dify-ui/src/alert-dialog/__tests__/index.spec.tsx b/packages/dify-ui/src/alert-dialog/__tests__/index.spec.tsx index 23fbcb19d6..5248be9a16 100644 --- a/packages/dify-ui/src/alert-dialog/__tests__/index.spec.tsx +++ b/packages/dify-ui/src/alert-dialog/__tests__/index.spec.tsx @@ -1,5 +1,4 @@ -import { fireEvent, render, screen, waitFor } from '@testing-library/react' -import { describe, expect, it, vi } from 'vitest' +import { render } from 'vitest-browser-react' import { AlertDialog, AlertDialogActions, @@ -11,10 +10,12 @@ import { AlertDialogTrigger, } from '../index' +const asHTMLElement = (element: HTMLElement | SVGElement) => element as HTMLElement + describe('AlertDialog wrapper', () => { describe('Rendering', () => { - it('should render alert dialog content when dialog is open', () => { - render( + it('should render alert dialog content when dialog is open', async () => { + const screen = await render( Confirm Delete @@ -23,13 +24,12 @@ describe('AlertDialog wrapper', () => { , ) - const dialog = screen.getByRole('alertdialog') - expect(dialog).toHaveTextContent('Confirm Delete') - expect(dialog).toHaveTextContent('This action cannot be undone.') + await expect.element(screen.getByRole('alertdialog')).toHaveTextContent('Confirm Delete') + await expect.element(screen.getByRole('alertdialog')).toHaveTextContent('This action cannot be undone.') }) - it('should not render content when dialog is closed', () => { - render( + it('should not render content when dialog is closed', async () => { + const screen = await render( Hidden Title @@ -37,13 +37,13 @@ describe('AlertDialog wrapper', () => { , ) - expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument() + expect(screen.container.querySelector('[role="alertdialog"]')).not.toBeInTheDocument() }) }) describe('Props', () => { - it('should apply custom className to popup', () => { - render( + it('should apply custom className to popup', async () => { + const screen = await render( Title @@ -51,12 +51,11 @@ describe('AlertDialog wrapper', () => { , ) - const dialog = screen.getByRole('alertdialog') - expect(dialog).toHaveClass('custom-class') + await expect.element(screen.getByRole('alertdialog')).toHaveClass('custom-class') }) - it('should not render a close button by default', () => { - render( + it('should not render a close button by default', async () => { + const screen = await render( Title @@ -64,13 +63,13 @@ describe('AlertDialog wrapper', () => { , ) - expect(screen.queryByRole('button', { name: 'Close' })).not.toBeInTheDocument() + expect(() => screen.getByRole('button', { name: 'Close' }).element()).toThrow() }) }) describe('User Interactions', () => { it('should open and close dialog when trigger and cancel button are clicked', async () => { - render( + const screen = await render( Open Dialog @@ -83,21 +82,21 @@ describe('AlertDialog wrapper', () => { , ) - expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument() + expect(screen.container.querySelector('[role="alertdialog"]')).not.toBeInTheDocument() - fireEvent.click(screen.getByRole('button', { name: 'Open Dialog' })) - expect(await screen.findByRole('alertdialog')).toHaveTextContent('Action Required') + asHTMLElement(screen.getByRole('button', { name: 'Open Dialog' }).element()).click() + await expect.element(screen.getByRole('alertdialog')).toHaveTextContent('Action Required') - fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) - await waitFor(() => { - expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument() + asHTMLElement(screen.getByRole('button', { name: 'Cancel' }).element()).click() + await vi.waitFor(() => { + expect(screen.container.querySelector('[role="alertdialog"]')).not.toBeInTheDocument() }) }) }) describe('Composition Helpers', () => { - it('should render actions wrapper and default confirm button styles', () => { - render( + it('should render actions wrapper and default confirm button styles', async () => { + const screen = await render( Action Required @@ -108,15 +107,14 @@ describe('AlertDialog wrapper', () => { , ) - expect(screen.getByTestId('actions')).toHaveClass('flex', 'items-start', 'justify-end', 'gap-2', 'self-stretch', 'p-6', 'custom-actions') - const confirmButton = screen.getByRole('button', { name: 'Confirm' }) - expect(confirmButton).toHaveClass('bg-components-button-destructive-primary-bg') + await expect.element(screen.getByTestId('actions')).toHaveClass('flex', 'items-start', 'justify-end', 'gap-2', 'self-stretch', 'p-6', 'custom-actions') + await expect.element(screen.getByRole('button', { name: 'Confirm' })).toHaveClass('bg-components-button-destructive-primary-bg') }) it('should keep dialog open after confirm click and close via cancel helper', async () => { const onConfirm = vi.fn() - render( + const screen = await render( Open Dialog @@ -129,16 +127,16 @@ describe('AlertDialog wrapper', () => { , ) - fireEvent.click(screen.getByRole('button', { name: 'Open Dialog' })) - expect(await screen.findByRole('alertdialog')).toBeInTheDocument() + asHTMLElement(screen.getByRole('button', { name: 'Open Dialog' }).element()).click() + await expect.element(screen.getByRole('alertdialog')).toBeInTheDocument() - fireEvent.click(screen.getByRole('button', { name: 'Confirm' })) + asHTMLElement(screen.getByRole('button', { name: 'Confirm' }).element()).click() expect(onConfirm).toHaveBeenCalledTimes(1) - expect(screen.getByRole('alertdialog')).toBeInTheDocument() + await expect.element(screen.getByRole('alertdialog')).toBeInTheDocument() - fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) - await waitFor(() => { - expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument() + asHTMLElement(screen.getByRole('button', { name: 'Cancel' }).element()).click() + await vi.waitFor(() => { + expect(screen.container.querySelector('[role="alertdialog"]')).not.toBeInTheDocument() }) }) }) diff --git a/packages/dify-ui/src/avatar/__tests__/index.spec.tsx b/packages/dify-ui/src/avatar/__tests__/index.spec.tsx index 8a384139c2..b0ea496282 100644 --- a/packages/dify-ui/src/avatar/__tests__/index.spec.tsx +++ b/packages/dify-ui/src/avatar/__tests__/index.spec.tsx @@ -1,25 +1,25 @@ -import { render, screen } from '@testing-library/react' +import { render } from 'vitest-browser-react' import { Avatar, AvatarFallback, AvatarImage, AvatarRoot } from '..' describe('Avatar', () => { describe('Rendering', () => { - it('should keep the fallback visible when avatar URL is provided before image load', () => { - render() + it('should keep the fallback visible when avatar URL is provided before image load', async () => { + const screen = await render() - expect(screen.getByText('J')).toBeInTheDocument() + await expect.element(screen.getByText('J')).toBeInTheDocument() }) - it('should render fallback with uppercase initial when avatar is null', () => { - render() + it('should render fallback with uppercase initial when avatar is null', async () => { + const screen = await render() - expect(screen.queryByRole('img')).not.toBeInTheDocument() - expect(screen.getByText('A')).toBeInTheDocument() + expect(screen.container.querySelector('img')).not.toBeInTheDocument() + await expect.element(screen.getByText('A')).toBeInTheDocument() }) - it('should render the fallback when avatar is provided', () => { - render() + it('should render the fallback when avatar is provided', async () => { + const screen = await render() - expect(screen.getByText('J')).toBeInTheDocument() + await expect.element(screen.getByText('J')).toBeInTheDocument() }) }) @@ -33,36 +33,36 @@ describe('Avatar', () => { { size: 'xl' as const, expectedClass: 'size-10' }, { size: '2xl' as const, expectedClass: 'size-12' }, { size: '3xl' as const, expectedClass: 'size-16' }, - ])('should apply $expectedClass for size="$size"', ({ size, expectedClass }) => { - const { container } = render() + ])('should apply $expectedClass for size="$size"', async ({ size, expectedClass }) => { + const screen = await render() - const root = container.firstElementChild as HTMLElement + const root = screen.container.firstElementChild as HTMLElement expect(root).toHaveClass(expectedClass) }) - it('should default to md size when size is not specified', () => { - const { container } = render() + it('should default to md size when size is not specified', async () => { + const screen = await render() - const root = container.firstElementChild as HTMLElement + const root = screen.container.firstElementChild as HTMLElement expect(root).toHaveClass('size-8') }) }) describe('className prop', () => { - it('should merge className with avatar variant classes on root', () => { - const { container } = render( + it('should merge className with avatar variant classes on root', async () => { + const screen = await render( , ) - const root = container.firstElementChild as HTMLElement + const root = screen.container.firstElementChild as HTMLElement expect(root).toHaveClass('custom-class') expect(root).toHaveClass('rounded-full', 'bg-primary-600') }) }) describe('Primitives', () => { - it('should support composed avatar usage through exported primitives', () => { - render( + it('should support composed avatar usage through exported primitives', async () => { + const screen = await render( @@ -71,17 +71,17 @@ describe('Avatar', () => { , ) - expect(screen.getByTestId('avatar-root')).toHaveClass('size-6') - expect(screen.getByText('J')).toBeInTheDocument() - expect(screen.getByText('J')).toHaveStyle({ backgroundColor: 'rgb(1, 2, 3)' }) + await expect.element(screen.getByTestId('avatar-root')).toHaveClass('size-6') + await expect.element(screen.getByText('J')).toBeInTheDocument() + await expect.element(screen.getByText('J')).toHaveStyle({ backgroundColor: 'rgb(1, 2, 3)' }) }) }) describe('Edge Cases', () => { - it('should handle empty string name gracefully', () => { - const { container } = render() + it('should handle empty string name gracefully', async () => { + const screen = await render() - const fallback = container.querySelector('.text-white') as HTMLElement + const fallback = screen.container.querySelector('.text-white') as HTMLElement expect(fallback).toBeInTheDocument() expect(fallback.textContent).toBe('') }) @@ -89,23 +89,23 @@ describe('Avatar', () => { it.each([ { name: '中文名', expected: '中', label: 'Chinese characters' }, { name: '123User', expected: '1', label: 'number' }, - ])('should display first character when name starts with $label', ({ name, expected }) => { - render() + ])('should display first character when name starts with $label', async ({ name, expected }) => { + const screen = await render() - expect(screen.getByText(expected)).toBeInTheDocument() + await expect.element(screen.getByText(expected)).toBeInTheDocument() }) - it('should handle empty string avatar as falsy value', () => { - render() + it('should handle empty string avatar as falsy value', async () => { + const screen = await render() - expect(screen.queryByRole('img')).not.toBeInTheDocument() - expect(screen.getByText('T')).toBeInTheDocument() + expect(screen.container.querySelector('img')).not.toBeInTheDocument() + await expect.element(screen.getByText('T')).toBeInTheDocument() }) }) describe('onLoadingStatusChange', () => { - it('should render the fallback when avatar and onLoadingStatusChange are provided', () => { - render( + it('should render the fallback when avatar and onLoadingStatusChange are provided', async () => { + const screen = await render( { />, ) - expect(screen.getByText('J')).toBeInTheDocument() + await expect.element(screen.getByText('J')).toBeInTheDocument() }) - it('should not render image when avatar is null even with onLoadingStatusChange', () => { + it('should not render image when avatar is null even with onLoadingStatusChange', async () => { const onStatusChange = vi.fn() - render( + const screen = await render( , ) - expect(screen.queryByRole('img')).not.toBeInTheDocument() + expect(screen.container.querySelector('img')).not.toBeInTheDocument() }) }) }) diff --git a/packages/dify-ui/src/button/__tests__/index.spec.tsx b/packages/dify-ui/src/button/__tests__/index.spec.tsx index e7b9c92c91..f3694c29af 100644 --- a/packages/dify-ui/src/button/__tests__/index.spec.tsx +++ b/packages/dify-ui/src/button/__tests__/index.spec.tsx @@ -1,46 +1,48 @@ -import { fireEvent, render, screen } from '@testing-library/react' +import { render } from 'vitest-browser-react' import { Button } from '../index' +const asHTMLElement = (element: HTMLElement | SVGElement) => element as HTMLElement + describe('Button', () => { describe('rendering', () => { - it('renders children text', () => { - render() - expect(screen.getByRole('button')).toHaveTextContent('Click me') + it('renders children text', async () => { + const screen = await render() + await expect.element(screen.getByRole('button')).toHaveTextContent('Click me') }) - it('renders as a native button element by default', () => { - render() - expect(screen.getByRole('button').tagName).toBe('BUTTON') + it('renders as a native button element by default', async () => { + const screen = await render() + expect(screen.getByRole('button').element().tagName).toBe('BUTTON') }) - it('defaults to type="button"', () => { - render() - expect(screen.getByRole('button')).toHaveAttribute('type', 'button') + it('defaults to type="button"', async () => { + const screen = await render() + await expect.element(screen.getByRole('button')).toHaveAttribute('type', 'button') }) - it('allows type override to submit', () => { - render() - expect(screen.getByRole('button')).toHaveAttribute('type', 'submit') + it('allows type override to submit', async () => { + const screen = await render() + await expect.element(screen.getByRole('button')).toHaveAttribute('type', 'submit') }) - it('renders custom element via render prop', () => { - render() - const link = screen.getByRole('link') - expect(link).toHaveTextContent('Link') - expect(link).toHaveAttribute('href', '/test') + it('renders custom element via render prop', async () => { + const screen = await render() + const button = screen.getByRole('button', { name: 'Link' }).element() + expect(button.tagName).toBe('A') + expect(button).toHaveAttribute('href', '/test') }) - it('applies base layout classes', () => { - render() - const btn = screen.getByRole('button') + it('applies base layout classes', async () => { + const screen = await render() + const btn = screen.getByRole('button').element() expect(btn).toHaveClass('inline-flex', 'justify-center', 'items-center', 'cursor-pointer') }) }) describe('variants', () => { - it('applies default secondary variant', () => { - render() - const btn = screen.getByRole('button') + it('applies default secondary variant', async () => { + const screen = await render() + const btn = screen.getByRole('button').element() expect(btn).toHaveClass('bg-components-button-secondary-bg', 'text-components-button-secondary-text') }) @@ -51,124 +53,124 @@ describe('Button', () => { { variant: 'ghost' as const, expectedClass: 'text-components-button-ghost-text' }, { variant: 'ghost-accent' as const, expectedClass: 'hover:bg-state-accent-hover' }, { variant: 'tertiary' as const, expectedClass: 'bg-components-button-tertiary-bg' }, - ])('applies $variant variant', ({ variant, expectedClass }) => { - render() - expect(screen.getByRole('button')).toHaveClass(expectedClass) + ])('applies $variant variant', async ({ variant, expectedClass }) => { + const screen = await render() + await expect.element(screen.getByRole('button')).toHaveClass(expectedClass) }) - it('applies destructive tone with default variant', () => { - render() - expect(screen.getByRole('button')).toHaveClass('bg-components-button-destructive-secondary-bg') + it('applies destructive tone with default variant', async () => { + const screen = await render() + await expect.element(screen.getByRole('button')).toHaveClass('bg-components-button-destructive-secondary-bg') }) - it('applies destructive tone with primary variant', () => { - render() - expect(screen.getByRole('button')).toHaveClass('bg-components-button-destructive-primary-bg') + it('applies destructive tone with primary variant', async () => { + const screen = await render() + await expect.element(screen.getByRole('button')).toHaveClass('bg-components-button-destructive-primary-bg') }) - it('applies destructive tone with tertiary variant', () => { - render() - expect(screen.getByRole('button')).toHaveClass('bg-components-button-destructive-tertiary-bg') + it('applies destructive tone with tertiary variant', async () => { + const screen = await render() + await expect.element(screen.getByRole('button')).toHaveClass('bg-components-button-destructive-tertiary-bg') }) - it('applies destructive tone with ghost variant', () => { - render() - expect(screen.getByRole('button')).toHaveClass('text-components-button-destructive-ghost-text') + it('applies destructive tone with ghost variant', async () => { + const screen = await render() + await expect.element(screen.getByRole('button')).toHaveClass('text-components-button-destructive-ghost-text') }) }) describe('sizes', () => { - it('applies default medium size', () => { - render() - expect(screen.getByRole('button')).toHaveClass('h-8', 'rounded-lg') + it('applies default medium size', async () => { + const screen = await render() + await expect.element(screen.getByRole('button')).toHaveClass('h-8', 'rounded-lg') }) it.each([ { size: 'small' as const, expectedClass: 'h-6' }, { size: 'medium' as const, expectedClass: 'h-8' }, { size: 'large' as const, expectedClass: 'h-9' }, - ])('applies $size size', ({ size, expectedClass }) => { - render() - expect(screen.getByRole('button')).toHaveClass(expectedClass) + ])('applies $size size', async ({ size, expectedClass }) => { + const screen = await render() + await expect.element(screen.getByRole('button')).toHaveClass(expectedClass) }) }) describe('loading', () => { - it('shows spinner when loading', () => { - render() - expect(screen.getByRole('button').querySelector('[aria-hidden="true"]')).toBeInTheDocument() + it('shows spinner when loading', async () => { + const screen = await render() + expect(screen.getByRole('button').element().querySelector('[aria-hidden="true"]')).toBeInTheDocument() }) - it('hides spinner when not loading', () => { - render() - expect(screen.getByRole('button').querySelector('[aria-hidden="true"]')).not.toBeInTheDocument() + it('hides spinner when not loading', async () => { + const screen = await render() + expect(screen.getByRole('button').element().querySelector('[aria-hidden="true"]')).not.toBeInTheDocument() }) - it('auto-disables when loading', () => { - render() - expect(screen.getByRole('button')).toBeDisabled() + it('auto-disables when loading', async () => { + const screen = await render() + await expect.element(screen.getByRole('button')).toBeDisabled() }) - it('sets aria-busy when loading', () => { - render() - expect(screen.getByRole('button')).toHaveAttribute('aria-busy', 'true') + it('sets aria-busy when loading', async () => { + const screen = await render() + await expect.element(screen.getByRole('button')).toHaveAttribute('aria-busy', 'true') }) - it('does not set aria-busy when not loading', () => { - render() - expect(screen.getByRole('button')).not.toHaveAttribute('aria-busy') + it('does not set aria-busy when not loading', async () => { + const screen = await render() + await expect.element(screen.getByRole('button')).not.toHaveAttribute('aria-busy') }) }) describe('disabled', () => { - it('disables button when disabled prop is set', () => { - render() - expect(screen.getByRole('button')).toBeDisabled() + it('disables button when disabled prop is set', async () => { + const screen = await render() + await expect.element(screen.getByRole('button')).toBeDisabled() }) - it('keeps focusable when loading with focusableWhenDisabled', () => { - render() - const button = screen.getByRole('button') + it('keeps focusable when loading with focusableWhenDisabled', async () => { + const screen = await render() + const button = screen.getByRole('button').element() expect(button).toHaveAttribute('aria-disabled', 'true') }) }) describe('events', () => { - it('fires onClick when clicked', () => { + it('fires onClick when clicked', async () => { const onClick = vi.fn() - render() - fireEvent.click(screen.getByRole('button')) + const screen = await render() + await screen.getByRole('button').click() expect(onClick).toHaveBeenCalledTimes(1) }) - it('does not fire onClick when disabled', () => { + it('does not fire onClick when disabled', async () => { const onClick = vi.fn() - render() - fireEvent.click(screen.getByRole('button')) + const screen = await render() + asHTMLElement(screen.getByRole('button').element()).click() expect(onClick).not.toHaveBeenCalled() }) - it('does not fire onClick when loading', () => { + it('does not fire onClick when loading', async () => { const onClick = vi.fn() - render() - fireEvent.click(screen.getByRole('button')) + const screen = await render() + asHTMLElement(screen.getByRole('button').element()).click() expect(onClick).not.toHaveBeenCalled() }) }) describe('className merging', () => { - it('merges custom className with variant classes', () => { - render() - const btn = screen.getByRole('button') + it('merges custom className with variant classes', async () => { + const screen = await render() + const btn = screen.getByRole('button').element() expect(btn).toHaveClass('custom-class') expect(btn).toHaveClass('inline-flex') }) }) describe('ref forwarding', () => { - it('forwards ref to the button element', () => { + it('forwards ref to the button element', async () => { let buttonRef: HTMLButtonElement | null = null - render( + await render(
- + + + + +
) diff --git a/web/app/components/billing/progress-bar/__tests__/index.spec.tsx b/web/app/components/billing/progress-bar/__tests__/index.spec.tsx deleted file mode 100644 index 4310fab19d..0000000000 --- a/web/app/components/billing/progress-bar/__tests__/index.spec.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { render, screen } from '@testing-library/react' -import ProgressBar from '../index' - -describe('ProgressBar', () => { - describe('Normal Mode (determinate)', () => { - it('renders with provided percent and color', () => { - render() - - const bar = screen.getByTestId('billing-progress-bar') - expect(bar.getAttribute('style')).toContain('width: 42%') - }) - - it('caps width at 100% when percent exceeds max', () => { - render() - - const bar = screen.getByTestId('billing-progress-bar') - expect(bar.getAttribute('style')).toContain('width: 100%') - }) - - it('renders with default color when no color prop is provided', () => { - render() - - const bar = screen.getByTestId('billing-progress-bar') - expect(bar.getAttribute('style')).toContain('width: 20%') - }) - }) - - describe('Indeterminate Mode', () => { - it('should render indeterminate progress bar when indeterminate is true', () => { - render() - - expect(screen.getByTestId('billing-progress-bar-indeterminate')).toBeInTheDocument() - }) - - it('should not render normal progress bar when indeterminate is true', () => { - render() - - expect(screen.queryByTestId('billing-progress-bar')).not.toBeInTheDocument() - expect(screen.getByTestId('billing-progress-bar-indeterminate')).toBeInTheDocument() - }) - - it('should render with different width based on indeterminateFull prop', () => { - const { rerender } = render( - , - ) - - const bar = screen.getByTestId('billing-progress-bar-indeterminate') - const partialClassName = bar.className - - rerender( - , - ) - - const fullClassName = screen.getByTestId('billing-progress-bar-indeterminate').className - expect(partialClassName).not.toBe(fullClassName) - }) - }) -}) diff --git a/web/app/components/billing/progress-bar/index.tsx b/web/app/components/billing/progress-bar/index.tsx deleted file mode 100644 index dfc2bf6fa9..0000000000 --- a/web/app/components/billing/progress-bar/index.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { cn } from '@langgenius/dify-ui/cn' - -type ProgressBarProps = { - percent: number - color: string - indeterminate?: boolean - indeterminateFull?: boolean // For Sandbox users: full width stripe -} - -const ProgressBar = ({ - percent = 0, - color = 'bg-components-progress-bar-progress-solid', - indeterminate = false, - indeterminateFull = false, -}: ProgressBarProps) => { - if (indeterminate) { - return ( -
-
-
- ) - } - - return ( -
-
-
- ) -} - -export default ProgressBar diff --git a/web/app/components/billing/usage-info/__tests__/index.spec.tsx b/web/app/components/billing/usage-info/__tests__/index.spec.tsx index 3cbab5c662..073c2e7fe2 100644 --- a/web/app/components/billing/usage-info/__tests__/index.spec.tsx +++ b/web/app/components/billing/usage-info/__tests__/index.spec.tsx @@ -71,8 +71,8 @@ describe('UsageInfo', () => { expect(screen.getByText('billing.plansCommon.unlimited')).toBeInTheDocument() }) - it('applies distinct styling when usage is close to or exceeds the limit', () => { - const { rerender } = render( + it('applies the neutral / warning / error tone as usage crosses thresholds', () => { + const { rerender, container } = render( { />, ) - const normalBarClass = screen.getByTestId('billing-progress-bar').className + expect(container.querySelector('.bg-components-progress-bar-progress-solid')).toBeInTheDocument() rerender( { />, ) - const warningBarClass = screen.getByTestId('billing-progress-bar').className - expect(warningBarClass).not.toBe(normalBarClass) + expect(container.querySelector('.bg-components-progress-warning-progress')).toBeInTheDocument() rerender( { />, ) - const errorBarClass = screen.getByTestId('billing-progress-bar').className - expect(errorBarClass).not.toBe(normalBarClass) - expect(errorBarClass).not.toBe(warningBarClass) + expect(container.querySelector('.bg-components-progress-error-progress')).toBeInTheDocument() }) it('does not render the icon when hideIcon is true', () => { @@ -126,8 +123,8 @@ describe('UsageInfo', () => { describe('Storage Mode', () => { describe('Below Threshold', () => { - it('should render indeterminate progress bar when usage is below threshold', () => { - render( + it('should render the redacted placeholder when usage is below threshold', () => { + const { container } = render( { />, ) - expect(screen.getByTestId('billing-progress-bar-indeterminate')).toBeInTheDocument() - expect(screen.queryByTestId('billing-progress-bar')).not.toBeInTheDocument() + expect(container.querySelector('[aria-hidden="true"]')).toBeInTheDocument() + expect(screen.queryByRole('meter')).not.toBeInTheDocument() }) it('should display "< threshold" format when usage is below threshold (non-sandbox)', () => { @@ -183,8 +180,8 @@ describe('UsageInfo', () => { expect(screen.getAllByText('MB').length).toBeGreaterThanOrEqual(1) }) - it('should render different indeterminate bar widths for sandbox vs non-sandbox', () => { - const { rerender } = render( + it('should render different placeholder widths for sandbox vs non-sandbox', () => { + const { rerender, container } = render( { />, ) - const sandboxBarClass = screen.getByTestId('billing-progress-bar-indeterminate').className + const sandboxBarClass = container.querySelector('.bg-progress-bar-indeterminate-stripe')!.className rerender( { />, ) - const nonSandboxBarClass = screen.getByTestId('billing-progress-bar-indeterminate').className + const nonSandboxBarClass = container.querySelector('.bg-progress-bar-indeterminate-stripe')!.className expect(sandboxBarClass).not.toBe(nonSandboxBarClass) }) }) describe('Sandbox Full Capacity', () => { - it('should render determinate progress bar when sandbox usage >= threshold', () => { - render( + it('should render the Meter when sandbox usage >= threshold', () => { + const { container } = render( { />, ) - expect(screen.getByTestId('billing-progress-bar')).toBeInTheDocument() - expect(screen.queryByTestId('billing-progress-bar-indeterminate')).not.toBeInTheDocument() + expect(screen.getByRole('meter')).toBeInTheDocument() + expect(container.querySelector('[aria-hidden="true"]')).toBeNull() }) it('should display "threshold / threshold unit" format when sandbox is at full capacity', () => { @@ -258,8 +255,8 @@ describe('UsageInfo', () => { }) describe('Pro/Team Users Above Threshold', () => { - it('should render normal progress bar when usage >= threshold', () => { - render( + it('should render the Meter when usage >= threshold', () => { + const { container } = render( { />, ) - expect(screen.getByTestId('billing-progress-bar')).toBeInTheDocument() - expect(screen.queryByTestId('billing-progress-bar-indeterminate')).not.toBeInTheDocument() + expect(screen.getByRole('meter')).toBeInTheDocument() + expect(container.querySelector('[aria-hidden="true"]')).toBeNull() }) it('should display actual usage when usage >= threshold', () => { diff --git a/web/app/components/billing/usage-info/__tests__/vector-space-info.spec.tsx b/web/app/components/billing/usage-info/__tests__/vector-space-info.spec.tsx index 041845ab3b..43e132c00f 100644 --- a/web/app/components/billing/usage-info/__tests__/vector-space-info.spec.tsx +++ b/web/app/components/billing/usage-info/__tests__/vector-space-info.spec.tsx @@ -3,6 +3,8 @@ import { defaultPlan } from '../../config' import { Plan } from '../../type' import VectorSpaceInfo from '../vector-space-info' +const queryPlaceholder = () => document.body.querySelector('[aria-hidden="true"]') + // Mock provider context with configurable plan let mockPlanType = Plan.sandbox let mockVectorSpaceUsage = 30 @@ -55,16 +57,11 @@ describe('VectorSpaceInfo', () => { mockVectorSpaceUsage = 30 }) - it('should render indeterminate progress bar when usage is below threshold', () => { + it('should render the redacted placeholder when usage is below threshold', () => { render() - expect(screen.getByTestId('billing-progress-bar-indeterminate')).toBeInTheDocument() - }) - - it('should render indeterminate bar for sandbox users', () => { - render() - - expect(screen.getByTestId('billing-progress-bar-indeterminate')).toBeInTheDocument() + expect(queryPlaceholder()).toBeInTheDocument() + expect(screen.queryByRole('meter')).not.toBeInTheDocument() }) it('should display "< 50" format for sandbox below threshold', () => { @@ -80,11 +77,11 @@ describe('VectorSpaceInfo', () => { mockVectorSpaceUsage = 50 }) - it('should render determinate progress bar when at full capacity', () => { + it('should render the Meter when at full capacity', () => { render() - expect(screen.getByTestId('billing-progress-bar')).toBeInTheDocument() - expect(screen.queryByTestId('billing-progress-bar-indeterminate')).not.toBeInTheDocument() + expect(screen.getByRole('meter')).toBeInTheDocument() + expect(queryPlaceholder()).toBeNull() }) it('should display "50 / 50 MB" format when at full capacity', () => { @@ -101,10 +98,11 @@ describe('VectorSpaceInfo', () => { mockVectorSpaceUsage = 30 }) - it('should render indeterminate progress bar when usage is below threshold', () => { + it('should render the redacted placeholder when usage is below threshold', () => { render() - expect(screen.getByTestId('billing-progress-bar-indeterminate')).toBeInTheDocument() + expect(queryPlaceholder()).toBeInTheDocument() + expect(screen.queryByRole('meter')).not.toBeInTheDocument() }) it('should display "< 50 / total" format when below threshold', () => { @@ -121,11 +119,11 @@ describe('VectorSpaceInfo', () => { mockVectorSpaceUsage = 100 }) - it('should render normal progress bar when usage >= threshold', () => { + it('should render the Meter when usage >= threshold', () => { render() - expect(screen.getByTestId('billing-progress-bar')).toBeInTheDocument() - expect(screen.queryByTestId('billing-progress-bar-indeterminate')).not.toBeInTheDocument() + expect(screen.getByRole('meter')).toBeInTheDocument() + expect(queryPlaceholder()).toBeNull() }) it('should display actual usage when above threshold', () => { @@ -142,10 +140,11 @@ describe('VectorSpaceInfo', () => { mockVectorSpaceUsage = 30 }) - it('should render indeterminate progress bar when usage is below threshold', () => { + it('should render the redacted placeholder when usage is below threshold', () => { render() - expect(screen.getByTestId('billing-progress-bar-indeterminate')).toBeInTheDocument() + expect(queryPlaceholder()).toBeInTheDocument() + expect(screen.queryByRole('meter')).not.toBeInTheDocument() }) it('should display "< 50 / total" format when below threshold', () => { @@ -163,11 +162,11 @@ describe('VectorSpaceInfo', () => { mockVectorSpaceUsage = 100 }) - it('should render normal progress bar when usage >= threshold', () => { + it('should render the Meter when usage >= threshold', () => { render() - expect(screen.getByTestId('billing-progress-bar')).toBeInTheDocument() - expect(screen.queryByTestId('billing-progress-bar-indeterminate')).not.toBeInTheDocument() + expect(screen.getByRole('meter')).toBeInTheDocument() + expect(queryPlaceholder()).toBeNull() }) it('should display actual usage when above threshold', () => { @@ -179,23 +178,26 @@ describe('VectorSpaceInfo', () => { }) describe('Pro/Team Plan Usage States', () => { - const renderAndGetBarClass = (usage: number) => { + const findToneClass = (usage: number) => { mockPlanType = Plan.professional mockVectorSpaceUsage = usage - const { unmount } = render() - const className = screen.getByTestId('billing-progress-bar').className + const { container, unmount } = render() + const indicator = container.querySelector( + '[class*="bg-components-progress-"]:not([class*="progress-bar-bg"])', + ) + const className = indicator?.className ?? '' unmount() return className } - it('should show distinct progress bar styling at different usage levels', () => { - const normalClass = renderAndGetBarClass(100) - const warningClass = renderAndGetBarClass(4100) - const errorClass = renderAndGetBarClass(5200) + it('should apply neutral / warning / error tone at distinct usage levels', () => { + const normalClass = findToneClass(100) + const warningClass = findToneClass(4100) + const errorClass = findToneClass(5200) - expect(normalClass).not.toBe(warningClass) - expect(warningClass).not.toBe(errorClass) - expect(normalClass).not.toBe(errorClass) + expect(normalClass).toContain('bg-components-progress-bar-progress-solid') + expect(warningClass).toContain('bg-components-progress-warning-progress') + expect(errorClass).toContain('bg-components-progress-error-progress') }) }) @@ -214,16 +216,11 @@ describe('VectorSpaceInfo', () => { expect(screen.getByText('102400MB')).toBeInTheDocument() }) - it('should render indeterminate progress bar when usage is below threshold', () => { + it('should render the redacted placeholder when usage is below threshold', () => { render() - expect(screen.getByTestId('billing-progress-bar-indeterminate')).toBeInTheDocument() - }) - - it('should render indeterminate bar for enterprise below threshold', () => { - render() - - expect(screen.getByTestId('billing-progress-bar-indeterminate')).toBeInTheDocument() + expect(queryPlaceholder()).toBeInTheDocument() + expect(screen.queryByRole('meter')).not.toBeInTheDocument() }) it('should display "< 50 / total" format when below threshold', () => { @@ -241,11 +238,11 @@ describe('VectorSpaceInfo', () => { mockVectorSpaceTotal = 102400 // 100 GB }) - it('should render normal progress bar when usage >= threshold', () => { + it('should render the Meter when usage >= threshold', () => { render() - expect(screen.getByTestId('billing-progress-bar')).toBeInTheDocument() - expect(screen.queryByTestId('billing-progress-bar-indeterminate')).not.toBeInTheDocument() + expect(screen.getByRole('meter')).toBeInTheDocument() + expect(queryPlaceholder()).toBeNull() }) it('should display actual usage when above threshold', () => { diff --git a/web/app/components/billing/usage-info/index.tsx b/web/app/components/billing/usage-info/index.tsx index a88523c4a5..68e889f320 100644 --- a/web/app/components/billing/usage-info/index.tsx +++ b/web/app/components/billing/usage-info/index.tsx @@ -1,11 +1,12 @@ 'use client' -import type { ComponentType, FC } from 'react' +import type { MeterTone } from '@langgenius/dify-ui/meter' +import type { ComponentType, FC, ReactNode } from 'react' import { cn } from '@langgenius/dify-ui/cn' +import { MeterIndicator, MeterRoot, MeterTrack } from '@langgenius/dify-ui/meter' import * as React from 'react' import { useTranslation } from 'react-i18next' import Tooltip from '@/app/components/base/tooltip' import { NUM_INFINITE } from '../config' -import ProgressBar from '../progress-bar' type Props = { className?: string @@ -26,8 +27,6 @@ type Props = { isSandboxPlan?: boolean } -const WARNING_THRESHOLD = 80 - const UsageInfo: FC = ({ className, Icon, @@ -47,20 +46,21 @@ const UsageInfo: FC = ({ }) => { const { t } = useTranslation() - // Special display logic for usage below threshold (only in storage mode) const isBelowThreshold = storageMode && usage < storageThreshold - // Sandbox at full capacity (usage >= threshold and it's sandbox plan) const isSandboxFull = storageMode && isSandboxPlan && usage >= storageThreshold - const percent = usage / total * 100 - const getProgressColor = () => { - if (percent >= 100) - return 'bg-components-progress-error-progress' - if (percent >= WARNING_THRESHOLD) - return 'bg-components-progress-warning-progress' - return 'bg-components-progress-bar-progress-solid' - } - const color = getProgressColor() + // Single source of truth: sandbox full is visually clamped to 100%; all other + // determinate cases show the real percent capped at 100. Tone derives from + // this, so we never need a separate tone override. + const rawPercent = total > 0 ? (usage / total) * 100 : 0 + const effectivePercent = isSandboxFull ? 100 : Math.min(rawPercent, 100) + const tone: MeterTone + = effectivePercent >= 100 + ? 'error' + : effectivePercent >= 80 + ? 'warning' + : 'neutral' + const isUnlimited = total === NUM_INFINITE let totalDisplay: string | number = isUnlimited ? t('plansCommon.unlimited', { ns: 'billing' }) : total if (!isUnlimited && unit && unitPosition === 'inline') @@ -68,35 +68,26 @@ const UsageInfo: FC = ({ const showUnit = !!unit && !isUnlimited && unitPosition === 'suffix' const resetText = resetHint ?? (typeof resetInDays === 'number' ? t('usagePage.resetsIn', { ns: 'billing', count: resetInDays }) : undefined) - const renderRightInfo = () => { - if (resetText) { - return ( + const rightInfo: ReactNode = resetText + ? (
{resetText}
) - } - if (showUnit) { - return ( -
- {unit} -
- ) - } - return null - } + : showUnit + ? ( +
+ {unit} +
+ ) + : null - // Render usage display - const renderUsageDisplay = () => { - // Storage mode: special display logic + const usageDisplay: ReactNode = (() => { if (storageMode) { - // Sandbox user at full capacity if (isSandboxFull) { return (
- - {storageThreshold} - + {storageThreshold} / {storageThreshold} @@ -106,7 +97,6 @@ const UsageInfo: FC = ({
) } - // Usage below threshold - show "< 50 MB" or "< 50 / 5GB" if (isBelowThreshold) { return (
@@ -125,7 +115,6 @@ const UsageInfo: FC = ({
) } - // Pro/Team users with usage >= threshold - show actual usage return (
{usage} @@ -135,7 +124,6 @@ const UsageInfo: FC = ({ ) } - // Default display (storageMode = false) return (
{usage} @@ -143,9 +131,32 @@ const UsageInfo: FC = ({ {totalDisplay}
) - } + })() - const renderWithTooltip = (children: React.ReactNode) => { + const bar: ReactNode = isBelowThreshold + ? ( + // Decorative "< N MB" placeholder — not a meter, not a progressbar. + ) } diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/credits-exhausted-alert.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/credits-exhausted-alert.tsx index 53abe1211d..69a7f488b3 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/credits-exhausted-alert.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/credits-exhausted-alert.tsx @@ -1,3 +1,4 @@ +import { MeterIndicator, MeterLabel, MeterRoot, MeterTrack } from '@langgenius/dify-ui/meter' import { Trans, useTranslation } from 'react-i18next' import { CreditsCoin } from '@/app/components/base/icons/src/vender/line/financeAndECommerce' import { useModalContextSelector } from '@/context/modal-context' @@ -21,7 +22,9 @@ export default function CreditsExhaustedAlert({ hasApiKeyFallback }: CreditsExha : 'modelProvider.card.creditsExhaustedDescription' const usedCredits = totalCredits - credits - const usagePercent = totalCredits > 0 ? Math.min((usedCredits / totalCredits) * 100, 100) : 100 + const hasTotal = totalCredits > 0 + const meterValue = hasTotal ? Math.min(usedCredits, totalCredits) : 1 + const meterMax = hasTotal ? totalCredits : 1 return (
@@ -45,11 +48,11 @@ export default function CreditsExhaustedAlert({ hasApiKeyFallback }: CreditsExha />
-
+
- + {t('modelProvider.card.usageLabel', { ns: 'common' })} - +
@@ -59,13 +62,10 @@ export default function CreditsExhaustedAlert({ hasApiKeyFallback }: CreditsExha
-
-
-
-
+ + + +
) } From 3e876e173af5d61b48e1710c5f9e3fd327a526f1 Mon Sep 17 00:00:00 2001 From: 99 Date: Sat, 18 Apr 2026 19:16:24 +0800 Subject: [PATCH 22/24] chore(api): adapt Graphon 0.2.2 upgrade (#35377) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- .../console/app/conversation_variables.py | 2 +- .../console/app/workflow_draft_variable.py | 6 +- .../service_api/app/conversation.py | 4 +- api/core/agent/base_agent_runner.py | 2 +- .../model_config/converter.py | 2 +- api/core/app/apps/agent_chat/app_runner.py | 2 +- .../easy_ui_based_generate_task_pipeline.py | 2 +- api/core/app/workflow/file_runtime.py | 7 +- api/core/app/workflow/layers/persistence.py | 2 +- api/core/datasource/datasource_manager.py | 4 +- api/core/entities/provider_configuration.py | 6 +- .../code_executor/template_transformer.py | 2 +- api/core/helper/moderation.py | 2 +- api/core/helper/ssrf_proxy.py | 44 +++ api/core/model_manager.py | 38 +- api/core/plugin/impl/model_runtime.py | 6 +- .../prompt/agent_history_prompt_transform.py | 2 +- api/core/rag/embedding/cached_embedding.py | 2 +- api/core/rag/extractor/word_extractor.py | 26 +- .../processor/paragraph_index_processor.py | 4 +- api/core/rag/retrieval/dataset_retrieval.py | 6 +- api/core/rag/splitter/fixed_text_splitter.py | 2 +- .../repositories/human_input_repository.py | 2 +- api/core/tools/tool_file_manager.py | 2 +- api/core/tools/tool_manager.py | 7 +- .../tools/utils/model_invocation_utils.py | 2 +- api/core/tools/workflow_as_tool/tool.py | 5 +- ...input_compat.py => human_input_adapter.py} | 88 ++++- api/core/workflow/node_factory.py | 30 +- api/core/workflow/node_runtime.py | 50 ++- api/core/workflow/nodes/agent/agent_node.py | 9 +- .../nodes/datasource/datasource_node.py | 10 +- .../knowledge_index/knowledge_index_node.py | 13 +- .../knowledge_retrieval_node.py | 33 +- api/factories/file_factory/builders.py | 20 +- api/fields/_value_type_serializer.py | 4 +- api/fields/conversation_variable_fields.py | 4 +- api/fields/workflow_fields.py | 2 +- api/models/human_input.py | 2 +- api/models/utils/file_input_compat.py | 128 ++++++- api/models/workflow.py | 13 +- .../src/dify_trace_tencent/tencent_trace.py | 36 +- .../tencent_trace/test_tencent_trace.py | 37 +- api/pyproject.toml | 2 +- api/services/app_service.py | 2 +- api/services/dataset_service.py | 2 +- .../human_input_delivery_test_service.py | 2 +- api/services/rag_pipeline/rag_pipeline.py | 2 +- api/services/variable_truncator.py | 2 +- .../workflow_draft_variable_service.py | 8 +- api/services/workflow_service.py | 15 +- api/tasks/mail_human_input_delivery_task.py | 2 +- .../test_datasource_node_integration.py | 24 +- .../workflow/nodes/test_code.py | 5 +- .../workflow/nodes/test_http.py | 10 +- .../workflow/nodes/test_llm.py | 5 +- .../nodes/test_parameter_extractor.py | 5 +- .../workflow/nodes/test_template_transform.py | 5 +- .../workflow/nodes/test_tool.py | 5 +- .../test_human_input_form_repository_impl.py | 2 +- .../test_human_input_resume_node_execution.py | 12 +- .../factories/test_storage_key_loader.py | 4 +- ...hemy_execution_extra_content_repository.py | 2 +- .../test_human_input_delivery_test.py | 2 +- .../test_human_input_delivery_test_service.py | 2 +- .../test_mail_human_input_delivery_task.py | 2 +- .../controllers/console/app/test_workflow.py | 2 +- .../app/workflow_draft_variables_test.py | 8 +- .../service_api/app/test_conversation.py | 28 ++ .../test_workflow_response_converter.py | 4 +- .../core/app/apps/test_pause_resume.py | 38 +- .../core/app/workflow/test_file_runtime.py | 6 +- .../core/app/workflow/test_node_factory.py | 4 +- .../datasource/test_datasource_manager.py | 4 +- .../entities/test_entities_model_entities.py | 2 +- api/tests/unit_tests/core/file/test_models.py | 40 +- .../unit_tests/core/helper/test_ssrf_proxy.py | 59 +++ .../test_model_provider_factory.py | 12 +- .../core/plugin/test_model_runtime_adapter.py | 2 +- .../core/plugin/utils/test_chunk_merger.py | 6 +- .../prompt/test_advanced_prompt_transform.py | 20 +- .../test_agent_history_prompt_transform.py | 2 +- .../core/prompt/test_prompt_transform.py | 2 +- .../core/rag/extractor/test_word_extractor.py | 39 +- .../test_human_input_form_repository_impl.py | 2 +- .../test_human_input_repository.py | 2 +- api/tests/unit_tests/core/test_file.py | 4 +- .../unit_tests/core/variables/test_segment.py | 36 +- .../variables/test_segment_type_validation.py | 2 +- .../graph_engine/test_mock_factory.py | 33 +- .../workflow/graph_engine/test_mock_nodes.py | 9 +- .../test_parallel_human_input_join_resume.py | 16 +- .../core/workflow/nodes/answer/test_answer.py | 18 +- .../nodes/datasource/test_datasource_node.py | 24 +- .../http_request/test_http_request_node.py | 10 +- .../human_input/test_email_delivery_config.py | 2 +- .../nodes/human_input/test_entities.py | 54 ++- .../test_human_input_form_filled_event.py | 35 +- .../test_iteration_child_engine_errors.py | 20 +- .../test_knowledge_index_node.py | 91 +++-- .../test_knowledge_retrieval_node.py | 64 ++-- .../workflow/nodes/list_operator/node_spec.py | 152 +++----- .../core/workflow/nodes/llm/test_llm_utils.py | 8 +- .../core/workflow/nodes/llm/test_node.py | 47 +-- .../template_transform_node_spec.py | 101 ++--- .../test_template_transform_node.py | 19 +- .../core/workflow/nodes/test_base_node.py | 65 ++-- .../nodes/test_document_extractor_node.py | 116 +++++- .../core/workflow/nodes/test_if_else.py | 76 ++-- .../core/workflow/nodes/test_list_operator.py | 32 +- .../nodes/test_start_node_json_object.py | 40 +- .../workflow/nodes/tool/test_tool_node.py | 20 +- .../trigger_plugin/test_trigger_event_node.py | 31 +- .../webhook/test_webhook_file_conversion.py | 13 +- .../nodes/webhook/test_webhook_node.py | 48 ++- .../core/workflow/test_human_input_adapter.py | 350 ++++++++++++++++++ .../core/workflow/test_human_input_compat.py | 184 --------- .../core/workflow/test_node_factory.py | 72 +++- .../core/workflow/test_node_runtime.py | 2 +- .../core/workflow/test_system_variable.py | 2 +- .../core/workflow/test_variable_pool.py | 14 +- .../workflow/test_workflow_entry_helpers.py | 18 +- .../factories/test_build_from_mapping.py | 2 +- .../factories/test_variable_factory.py | 52 +-- .../test_conversation_variable_fields.py | 47 +++ .../unit_tests/fields/test_file_fields.py | 2 +- .../models/test_file_input_compat.py | 149 ++++++++ api/tests/unit_tests/models/test_workflow.py | 50 +-- .../services/test_variable_truncator.py | 4 +- .../services/test_workflow_service.py | 21 +- .../workflow/test_draft_var_loader_simple.py | 8 +- .../test_workflow_draft_variable_service.py | 4 +- .../test_workflow_human_input_delivery.py | 39 +- api/uv.lock | 8 +- 134 files changed, 2154 insertions(+), 1134 deletions(-) rename api/core/workflow/{human_input_compat.py => human_input_adapter.py} (74%) create mode 100644 api/tests/unit_tests/core/workflow/test_human_input_adapter.py delete mode 100644 api/tests/unit_tests/core/workflow/test_human_input_compat.py create mode 100644 api/tests/unit_tests/fields/test_conversation_variable_fields.py create mode 100644 api/tests/unit_tests/models/test_file_input_compat.py diff --git a/api/controllers/console/app/conversation_variables.py b/api/controllers/console/app/conversation_variables.py index cead33d14f..9c8b095b9f 100644 --- a/api/controllers/console/app/conversation_variables.py +++ b/api/controllers/console/app/conversation_variables.py @@ -45,7 +45,7 @@ class ConversationVariableResponse(ResponseModel): def _normalize_value_type(cls, value: Any) -> str: exposed_type = getattr(value, "exposed_type", None) if callable(exposed_type): - return str(exposed_type().value) + return str(exposed_type()) if isinstance(value, str): return value try: diff --git a/api/controllers/console/app/workflow_draft_variable.py b/api/controllers/console/app/workflow_draft_variable.py index f6319573e0..e32ba5f66c 100644 --- a/api/controllers/console/app/workflow_draft_variable.py +++ b/api/controllers/console/app/workflow_draft_variable.py @@ -102,7 +102,7 @@ def _serialize_var_value(variable: WorkflowDraftVariable): def _serialize_variable_type(workflow_draft_var: WorkflowDraftVariable) -> str: value_type = workflow_draft_var.value_type - return value_type.exposed_type().value + return str(value_type.exposed_type()) class FullContentDict(TypedDict): @@ -122,7 +122,7 @@ def _serialize_full_content(variable: WorkflowDraftVariable) -> FullContentDict result: FullContentDict = { "size_bytes": variable_file.size, - "value_type": variable_file.value_type.exposed_type().value, + "value_type": str(variable_file.value_type.exposed_type()), "length": variable_file.length, "download_url": file_helpers.get_signed_file_url(variable_file.upload_file_id, as_attachment=True), } @@ -598,7 +598,7 @@ class EnvironmentVariableCollectionApi(Resource): "name": v.name, "description": v.description, "selector": v.selector, - "value_type": v.value_type.exposed_type().value, + "value_type": str(v.value_type.exposed_type()), "value": v.value, # Do not track edited for env vars. "edited": False, diff --git a/api/controllers/service_api/app/conversation.py b/api/controllers/service_api/app/conversation.py index c4353ca7b8..ca4b18cb5e 100644 --- a/api/controllers/service_api/app/conversation.py +++ b/api/controllers/service_api/app/conversation.py @@ -84,10 +84,10 @@ class ConversationVariableResponse(ResponseModel): def normalize_value_type(cls, value: Any) -> str: exposed_type = getattr(value, "exposed_type", None) if callable(exposed_type): - return str(exposed_type().value) + return str(exposed_type()) if isinstance(value, str): try: - return str(SegmentType(value).exposed_type().value) + return str(SegmentType(value).exposed_type()) except ValueError: return value try: diff --git a/api/core/agent/base_agent_runner.py b/api/core/agent/base_agent_runner.py index 790602ef5d..c22102c2ba 100644 --- a/api/core/agent/base_agent_runner.py +++ b/api/core/agent/base_agent_runner.py @@ -42,7 +42,7 @@ from graphon.model_runtime.entities import ( ) from graphon.model_runtime.entities.message_entities import ImagePromptMessageContent, PromptMessageContentUnionTypes from graphon.model_runtime.entities.model_entities import ModelFeature -from graphon.model_runtime.model_providers.__base.large_language_model import LargeLanguageModel +from graphon.model_runtime.model_providers.base.large_language_model import LargeLanguageModel from models.enums import CreatorUserRole from models.model import Conversation, Message, MessageAgentThought, MessageFile diff --git a/api/core/app/app_config/easy_ui_based_app/model_config/converter.py b/api/core/app/app_config/easy_ui_based_app/model_config/converter.py index dbd7527fc6..5df3df2b3e 100644 --- a/api/core/app/app_config/easy_ui_based_app/model_config/converter.py +++ b/api/core/app/app_config/easy_ui_based_app/model_config/converter.py @@ -7,7 +7,7 @@ from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotIni from core.plugin.impl.model_runtime_factory import create_plugin_provider_manager from graphon.model_runtime.entities.llm_entities import LLMMode from graphon.model_runtime.entities.model_entities import ModelPropertyKey, ModelType -from graphon.model_runtime.model_providers.__base.large_language_model import LargeLanguageModel +from graphon.model_runtime.model_providers.base.large_language_model import LargeLanguageModel class ModelConfigConverter: diff --git a/api/core/app/apps/agent_chat/app_runner.py b/api/core/app/apps/agent_chat/app_runner.py index 09ddce327e..cae0eee0df 100644 --- a/api/core/app/apps/agent_chat/app_runner.py +++ b/api/core/app/apps/agent_chat/app_runner.py @@ -18,7 +18,7 @@ from core.moderation.base import ModerationError from extensions.ext_database import db from graphon.model_runtime.entities.llm_entities import LLMMode from graphon.model_runtime.entities.model_entities import ModelFeature, ModelPropertyKey -from graphon.model_runtime.model_providers.__base.large_language_model import LargeLanguageModel +from graphon.model_runtime.model_providers.base.large_language_model import LargeLanguageModel from models.model import App, Conversation, Message logger = logging.getLogger(__name__) diff --git a/api/core/app/task_pipeline/easy_ui_based_generate_task_pipeline.py b/api/core/app/task_pipeline/easy_ui_based_generate_task_pipeline.py index dfe6133cb6..e2e07ebaff 100644 --- a/api/core/app/task_pipeline/easy_ui_based_generate_task_pipeline.py +++ b/api/core/app/task_pipeline/easy_ui_based_generate_task_pipeline.py @@ -59,7 +59,7 @@ from graphon.model_runtime.entities.message_entities import ( AssistantPromptMessage, TextPromptMessageContent, ) -from graphon.model_runtime.model_providers.__base.large_language_model import LargeLanguageModel +from graphon.model_runtime.model_providers.base.large_language_model import LargeLanguageModel from libs.datetime_utils import naive_utc_now from models.model import AppMode, Conversation, Message, MessageAgentThought, MessageFile, UploadFile diff --git a/api/core/app/workflow/file_runtime.py b/api/core/app/workflow/file_runtime.py index 68e5e5f0c8..3a6f9d575a 100644 --- a/api/core/app/workflow/file_runtime.py +++ b/api/core/app/workflow/file_runtime.py @@ -12,13 +12,14 @@ from typing import TYPE_CHECKING, Literal from configs import dify_config from core.app.file_access import DatabaseFileAccessController, FileAccessControllerProtocol from core.db.session_factory import session_factory -from core.helper.ssrf_proxy import ssrf_proxy +from core.helper.ssrf_proxy import graphon_ssrf_proxy from core.tools.signature import sign_tool_file from core.workflow.file_reference import parse_file_reference from extensions.ext_storage import storage from graphon.file import FileTransferMethod -from graphon.file.protocols import HttpResponseProtocol, WorkflowFileRuntimeProtocol +from graphon.file.protocols import WorkflowFileRuntimeProtocol from graphon.file.runtime import set_workflow_file_runtime +from graphon.http.protocols import HttpResponseProtocol if TYPE_CHECKING: from graphon.file import File @@ -43,7 +44,7 @@ class DifyWorkflowFileRuntime(WorkflowFileRuntimeProtocol): return dify_config.MULTIMODAL_SEND_FORMAT def http_get(self, url: str, *, follow_redirects: bool = True) -> HttpResponseProtocol: - return ssrf_proxy.get(url, follow_redirects=follow_redirects) + return graphon_ssrf_proxy.get(url, follow_redirects=follow_redirects) def storage_load(self, path: str, *, stream: bool = False) -> bytes | Generator: return storage.load(path, stream=stream) diff --git a/api/core/app/workflow/layers/persistence.py b/api/core/app/workflow/layers/persistence.py index 87f005a250..d521304615 100644 --- a/api/core/app/workflow/layers/persistence.py +++ b/api/core/app/workflow/layers/persistence.py @@ -349,7 +349,7 @@ class WorkflowPersistenceLayer(GraphEngineLayer): execution.total_tokens = runtime_state.total_tokens execution.total_steps = runtime_state.node_run_steps execution.outputs = execution.outputs or runtime_state.outputs - execution.exceptions_count = runtime_state.exceptions_count + execution.exceptions_count = max(execution.exceptions_count, runtime_state.exceptions_count) def _update_node_execution( self, diff --git a/api/core/datasource/datasource_manager.py b/api/core/datasource/datasource_manager.py index dc831e5cac..f0dcb13b62 100644 --- a/api/core/datasource/datasource_manager.py +++ b/api/core/datasource/datasource_manager.py @@ -352,11 +352,11 @@ class DatasourceManager: raise ValueError(f"UploadFile not found for file_id={file_id}, tenant_id={tenant_id}") file_info = File( - id=upload_file.id, + file_id=upload_file.id, filename=upload_file.name, extension="." + upload_file.extension, mime_type=upload_file.mime_type, - type=FileType.CUSTOM, + file_type=FileType.CUSTOM, transfer_method=FileTransferMethod.LOCAL_FILE, remote_url=upload_file.source_url, reference=build_file_reference(record_id=str(upload_file.id)), diff --git a/api/core/entities/provider_configuration.py b/api/core/entities/provider_configuration.py index 6bbf163c9d..38b87e2cd1 100644 --- a/api/core/entities/provider_configuration.py +++ b/api/core/entities/provider_configuration.py @@ -31,7 +31,7 @@ from graphon.model_runtime.entities.provider_entities import ( FormType, ProviderEntity, ) -from graphon.model_runtime.model_providers.__base.ai_model import AIModel +from graphon.model_runtime.model_providers.base.ai_model import AIModel from graphon.model_runtime.model_providers.model_provider_factory import ModelProviderFactory from graphon.model_runtime.runtime import ModelRuntime from libs.datetime_utils import naive_utc_now @@ -363,7 +363,7 @@ class ProviderConfiguration(BaseModel): ) for key, value in validated_credentials.items(): - if key in provider_credential_secret_variables: + if key in provider_credential_secret_variables and isinstance(value, str): validated_credentials[key] = encrypter.encrypt_token(self.tenant_id, value) return validated_credentials @@ -912,7 +912,7 @@ class ProviderConfiguration(BaseModel): ) for key, value in validated_credentials.items(): - if key in provider_credential_secret_variables: + if key in provider_credential_secret_variables and isinstance(value, str): validated_credentials[key] = encrypter.encrypt_token(self.tenant_id, value) return validated_credentials diff --git a/api/core/helper/code_executor/template_transformer.py b/api/core/helper/code_executor/template_transformer.py index b96a9ce380..38864a1830 100644 --- a/api/core/helper/code_executor/template_transformer.py +++ b/api/core/helper/code_executor/template_transformer.py @@ -102,7 +102,7 @@ class TemplateTransformer(ABC): @classmethod def serialize_inputs(cls, inputs: Mapping[str, Any]) -> str: - inputs_json_str = dumps_with_segments(inputs, ensure_ascii=False).encode() + inputs_json_str = dumps_with_segments(inputs).encode() input_base64_encoded = b64encode(inputs_json_str).decode("utf-8") return input_base64_encoded diff --git a/api/core/helper/moderation.py b/api/core/helper/moderation.py index dc37a36943..f169f247cf 100644 --- a/api/core/helper/moderation.py +++ b/api/core/helper/moderation.py @@ -8,7 +8,7 @@ from core.plugin.impl.model_runtime_factory import create_plugin_model_provider_ from extensions.ext_hosting_provider import hosting_configuration from graphon.model_runtime.entities.model_entities import ModelType from graphon.model_runtime.errors.invoke import InvokeBadRequestError -from graphon.model_runtime.model_providers.__base.moderation_model import ModerationModel +from graphon.model_runtime.model_providers.base.moderation_model import ModerationModel from models.provider import ProviderType logger = logging.getLogger(__name__) diff --git a/api/core/helper/ssrf_proxy.py b/api/core/helper/ssrf_proxy.py index e38592bb7b..91e92712b7 100644 --- a/api/core/helper/ssrf_proxy.py +++ b/api/core/helper/ssrf_proxy.py @@ -12,6 +12,7 @@ from pydantic import TypeAdapter, ValidationError from configs import dify_config from core.helper.http_client_pooling import get_pooled_http_client from core.tools.errors import ToolSSRFError +from graphon.http.response import HttpResponse logger = logging.getLogger(__name__) @@ -267,4 +268,47 @@ class SSRFProxy: return patch(url=url, max_retries=max_retries, **kwargs) +def _to_graphon_http_response(response: httpx.Response) -> HttpResponse: + """Convert an ``httpx`` response into Graphon's transport-agnostic wrapper.""" + return HttpResponse( + status_code=response.status_code, + headers=dict(response.headers), + content=response.content, + url=str(response.url) if response.url else None, + reason_phrase=response.reason_phrase, + fallback_text=response.text, + ) + + +class GraphonSSRFProxy: + """Adapter exposing SSRF helpers behind Graphon's ``HttpClientProtocol``.""" + + @property + def max_retries_exceeded_error(self) -> type[Exception]: + return max_retries_exceeded_error + + @property + def request_error(self) -> type[Exception]: + return request_error + + def get(self, url: str, max_retries: int = SSRF_DEFAULT_MAX_RETRIES, **kwargs: Any) -> HttpResponse: + return _to_graphon_http_response(get(url=url, max_retries=max_retries, **kwargs)) + + def head(self, url: str, max_retries: int = SSRF_DEFAULT_MAX_RETRIES, **kwargs: Any) -> HttpResponse: + return _to_graphon_http_response(head(url=url, max_retries=max_retries, **kwargs)) + + def post(self, url: str, max_retries: int = SSRF_DEFAULT_MAX_RETRIES, **kwargs: Any) -> HttpResponse: + return _to_graphon_http_response(post(url=url, max_retries=max_retries, **kwargs)) + + def put(self, url: str, max_retries: int = SSRF_DEFAULT_MAX_RETRIES, **kwargs: Any) -> HttpResponse: + return _to_graphon_http_response(put(url=url, max_retries=max_retries, **kwargs)) + + def delete(self, url: str, max_retries: int = SSRF_DEFAULT_MAX_RETRIES, **kwargs: Any) -> HttpResponse: + return _to_graphon_http_response(delete(url=url, max_retries=max_retries, **kwargs)) + + def patch(self, url: str, max_retries: int = SSRF_DEFAULT_MAX_RETRIES, **kwargs: Any) -> HttpResponse: + return _to_graphon_http_response(patch(url=url, max_retries=max_retries, **kwargs)) + + ssrf_proxy = SSRFProxy() +graphon_ssrf_proxy = GraphonSSRFProxy() diff --git a/api/core/model_manager.py b/api/core/model_manager.py index d8d8dfedd8..86d0e3baaa 100644 --- a/api/core/model_manager.py +++ b/api/core/model_manager.py @@ -1,6 +1,6 @@ import logging from collections.abc import Callable, Generator, Iterable, Mapping, Sequence -from typing import IO, Any, Literal, Optional, Union, cast, overload +from typing import IO, Any, Literal, Optional, ParamSpec, TypeVar, Union, cast, overload from configs import dify_config from core.entities import PluginCredentialType @@ -18,15 +18,17 @@ from graphon.model_runtime.entities.model_entities import AIModelEntity, ModelFe from graphon.model_runtime.entities.rerank_entities import MultimodalRerankInput, RerankResult from graphon.model_runtime.entities.text_embedding_entities import EmbeddingResult from graphon.model_runtime.errors.invoke import InvokeAuthorizationError, InvokeConnectionError, InvokeRateLimitError -from graphon.model_runtime.model_providers.__base.large_language_model import LargeLanguageModel -from graphon.model_runtime.model_providers.__base.moderation_model import ModerationModel -from graphon.model_runtime.model_providers.__base.rerank_model import RerankModel -from graphon.model_runtime.model_providers.__base.speech2text_model import Speech2TextModel -from graphon.model_runtime.model_providers.__base.text_embedding_model import TextEmbeddingModel -from graphon.model_runtime.model_providers.__base.tts_model import TTSModel +from graphon.model_runtime.model_providers.base.large_language_model import LargeLanguageModel +from graphon.model_runtime.model_providers.base.moderation_model import ModerationModel +from graphon.model_runtime.model_providers.base.rerank_model import RerankModel +from graphon.model_runtime.model_providers.base.speech2text_model import Speech2TextModel +from graphon.model_runtime.model_providers.base.text_embedding_model import TextEmbeddingModel +from graphon.model_runtime.model_providers.base.tts_model import TTSModel from models.provider import ProviderType logger = logging.getLogger(__name__) +P = ParamSpec("P") +R = TypeVar("R") class ModelInstance: @@ -168,7 +170,7 @@ class ModelInstance: return cast( Union[LLMResult, Generator], self._round_robin_invoke( - function=self.model_type_instance.invoke, + self.model_type_instance.invoke, model=self.model_name, credentials=self.credentials, prompt_messages=list(prompt_messages), @@ -193,7 +195,7 @@ class ModelInstance: if not isinstance(self.model_type_instance, LargeLanguageModel): raise Exception("Model type instance is not LargeLanguageModel") return self._round_robin_invoke( - function=self.model_type_instance.get_num_tokens, + self.model_type_instance.get_num_tokens, model=self.model_name, credentials=self.credentials, prompt_messages=list(prompt_messages), @@ -213,7 +215,7 @@ class ModelInstance: if not isinstance(self.model_type_instance, TextEmbeddingModel): raise Exception("Model type instance is not TextEmbeddingModel") return self._round_robin_invoke( - function=self.model_type_instance.invoke, + self.model_type_instance.invoke, model=self.model_name, credentials=self.credentials, texts=texts, @@ -235,7 +237,7 @@ class ModelInstance: if not isinstance(self.model_type_instance, TextEmbeddingModel): raise Exception("Model type instance is not TextEmbeddingModel") return self._round_robin_invoke( - function=self.model_type_instance.invoke, + self.model_type_instance.invoke, model=self.model_name, credentials=self.credentials, multimodel_documents=multimodel_documents, @@ -252,7 +254,7 @@ class ModelInstance: if not isinstance(self.model_type_instance, TextEmbeddingModel): raise Exception("Model type instance is not TextEmbeddingModel") return self._round_robin_invoke( - function=self.model_type_instance.get_num_tokens, + self.model_type_instance.get_num_tokens, model=self.model_name, credentials=self.credentials, texts=texts, @@ -277,7 +279,7 @@ class ModelInstance: if not isinstance(self.model_type_instance, RerankModel): raise Exception("Model type instance is not RerankModel") return self._round_robin_invoke( - function=self.model_type_instance.invoke, + self.model_type_instance.invoke, model=self.model_name, credentials=self.credentials, query=query, @@ -305,7 +307,7 @@ class ModelInstance: if not isinstance(self.model_type_instance, RerankModel): raise Exception("Model type instance is not RerankModel") return self._round_robin_invoke( - function=self.model_type_instance.invoke_multimodal_rerank, + self.model_type_instance.invoke_multimodal_rerank, model=self.model_name, credentials=self.credentials, query=query, @@ -324,7 +326,7 @@ class ModelInstance: if not isinstance(self.model_type_instance, ModerationModel): raise Exception("Model type instance is not ModerationModel") return self._round_robin_invoke( - function=self.model_type_instance.invoke, + self.model_type_instance.invoke, model=self.model_name, credentials=self.credentials, text=text, @@ -340,7 +342,7 @@ class ModelInstance: if not isinstance(self.model_type_instance, Speech2TextModel): raise Exception("Model type instance is not Speech2TextModel") return self._round_robin_invoke( - function=self.model_type_instance.invoke, + self.model_type_instance.invoke, model=self.model_name, credentials=self.credentials, file=file, @@ -357,14 +359,14 @@ class ModelInstance: if not isinstance(self.model_type_instance, TTSModel): raise Exception("Model type instance is not TTSModel") return self._round_robin_invoke( - function=self.model_type_instance.invoke, + self.model_type_instance.invoke, model=self.model_name, credentials=self.credentials, content_text=content_text, voice=voice, ) - def _round_robin_invoke[**P, R](self, function: Callable[P, R], *args: P.args, **kwargs: P.kwargs) -> R: + def _round_robin_invoke(self, function: Callable[P, R], *args: P.args, **kwargs: P.kwargs) -> R: """ Round-robin invoke :param function: function to invoke diff --git a/api/core/plugin/impl/model_runtime.py b/api/core/plugin/impl/model_runtime.py index e3fba4ef3a..4e66d58b5e 100644 --- a/api/core/plugin/impl/model_runtime.py +++ b/api/core/plugin/impl/model_runtime.py @@ -66,15 +66,15 @@ class PluginModelRuntime(ModelRuntime): if not provider_schema.icon_small: raise ValueError(f"Provider {provider} does not have small icon.") file_name = ( - provider_schema.icon_small.zh_Hans if lang.lower() == "zh_hans" else provider_schema.icon_small.en_US + provider_schema.icon_small.zh_hans if lang.lower() == "zh_hans" else provider_schema.icon_small.en_us ) elif icon_type.lower() == "icon_small_dark": if not provider_schema.icon_small_dark: raise ValueError(f"Provider {provider} does not have small dark icon.") file_name = ( - provider_schema.icon_small_dark.zh_Hans + provider_schema.icon_small_dark.zh_hans if lang.lower() == "zh_hans" - else provider_schema.icon_small_dark.en_US + else provider_schema.icon_small_dark.en_us ) else: raise ValueError(f"Unsupported icon type: {icon_type}.") diff --git a/api/core/prompt/agent_history_prompt_transform.py b/api/core/prompt/agent_history_prompt_transform.py index 8f1d51f08a..7c6280fe93 100644 --- a/api/core/prompt/agent_history_prompt_transform.py +++ b/api/core/prompt/agent_history_prompt_transform.py @@ -10,7 +10,7 @@ from graphon.model_runtime.entities.message_entities import ( SystemPromptMessage, UserPromptMessage, ) -from graphon.model_runtime.model_providers.__base.large_language_model import LargeLanguageModel +from graphon.model_runtime.model_providers.base.large_language_model import LargeLanguageModel class AgentHistoryPromptTransform(PromptTransform): diff --git a/api/core/rag/embedding/cached_embedding.py b/api/core/rag/embedding/cached_embedding.py index 4926f44f16..a9995778f7 100644 --- a/api/core/rag/embedding/cached_embedding.py +++ b/api/core/rag/embedding/cached_embedding.py @@ -14,7 +14,7 @@ from core.rag.embedding.embedding_base import Embeddings from extensions.ext_database import db from extensions.ext_redis import redis_client from graphon.model_runtime.entities.model_entities import ModelPropertyKey -from graphon.model_runtime.model_providers.__base.text_embedding_model import TextEmbeddingModel +from graphon.model_runtime.model_providers.base.text_embedding_model import TextEmbeddingModel from libs import helper from models.dataset import Embedding diff --git a/api/core/rag/extractor/word_extractor.py b/api/core/rag/extractor/word_extractor.py index 052fca930d..0330a43b28 100644 --- a/api/core/rag/extractor/word_extractor.py +++ b/api/core/rag/extractor/word_extractor.py @@ -3,6 +3,7 @@ Supports local file paths and remote URLs (downloaded via `core.helper.ssrf_proxy`). """ +import inspect import logging import mimetypes import os @@ -36,8 +37,11 @@ class WordExtractor(BaseExtractor): file_path: Path to the file to load. """ + _closed: bool + def __init__(self, file_path: str, tenant_id: str, user_id: str): """Initialize with file path.""" + self._closed = False self.file_path = file_path self.tenant_id = tenant_id self.user_id = user_id @@ -65,9 +69,27 @@ class WordExtractor(BaseExtractor): elif not os.path.isfile(self.file_path): raise ValueError(f"File path {self.file_path} is not a valid file or url") + def close(self) -> None: + """Best-effort cleanup for downloaded temporary files.""" + if getattr(self, "_closed", False): + return + + self._closed = True + temp_file = getattr(self, "temp_file", None) + if temp_file is None: + return + + try: + close_result = temp_file.close() + if inspect.isawaitable(close_result): + close_awaitable = getattr(close_result, "close", None) + if callable(close_awaitable): + close_awaitable() + except Exception: + logger.debug("Failed to cleanup downloaded word temp file", exc_info=True) + def __del__(self): - if hasattr(self, "temp_file"): - self.temp_file.close() + self.close() def extract(self) -> list[Document]: """Load given path as single page.""" diff --git a/api/core/rag/index_processor/processor/paragraph_index_processor.py b/api/core/rag/index_processor/processor/paragraph_index_processor.py index f8242efe31..7ffa9afafd 100644 --- a/api/core/rag/index_processor/processor/paragraph_index_processor.py +++ b/api/core/rag/index_processor/processor/paragraph_index_processor.py @@ -609,11 +609,11 @@ class ParagraphIndexProcessor(BaseIndexProcessor): try: # Create File object directly (similar to DatasetRetrieval) file_obj = File( - id=upload_file.id, + file_id=upload_file.id, filename=upload_file.name, extension="." + upload_file.extension, mime_type=upload_file.mime_type, - type=FileType.IMAGE, + file_type=FileType.IMAGE, transfer_method=FileTransferMethod.LOCAL_FILE, remote_url=upload_file.source_url, reference=build_file_reference( diff --git a/api/core/rag/retrieval/dataset_retrieval.py b/api/core/rag/retrieval/dataset_retrieval.py index 1453fe020b..5631b3a921 100644 --- a/api/core/rag/retrieval/dataset_retrieval.py +++ b/api/core/rag/retrieval/dataset_retrieval.py @@ -68,7 +68,7 @@ from graphon.file import File, FileTransferMethod, FileType from graphon.model_runtime.entities.llm_entities import LLMMode, LLMResult, LLMUsage from graphon.model_runtime.entities.message_entities import PromptMessage, PromptMessageRole, PromptMessageTool from graphon.model_runtime.entities.model_entities import ModelFeature, ModelType -from graphon.model_runtime.model_providers.__base.large_language_model import LargeLanguageModel +from graphon.model_runtime.model_providers.base.large_language_model import LargeLanguageModel from libs.helper import parse_uuid_str_or_none from libs.json_in_md_parser import parse_and_check_json_markdown from models import UploadFile @@ -517,11 +517,11 @@ class DatasetRetrieval: if attachments_with_bindings: for _, upload_file in attachments_with_bindings: attachment_info = File( - id=upload_file.id, + file_id=upload_file.id, filename=upload_file.name, extension="." + upload_file.extension, mime_type=upload_file.mime_type, - type=FileType.IMAGE, + file_type=FileType.IMAGE, transfer_method=FileTransferMethod.LOCAL_FILE, remote_url=upload_file.source_url, reference=build_file_reference( diff --git a/api/core/rag/splitter/fixed_text_splitter.py b/api/core/rag/splitter/fixed_text_splitter.py index 2581c354dd..66b375dad1 100644 --- a/api/core/rag/splitter/fixed_text_splitter.py +++ b/api/core/rag/splitter/fixed_text_splitter.py @@ -9,7 +9,7 @@ from typing import Any, Literal from core.model_manager import ModelInstance from core.rag.splitter.text_splitter import RecursiveCharacterTextSplitter -from graphon.model_runtime.model_providers.__base.tokenizers.gpt2_tokenizer import GPT2Tokenizer +from graphon.model_runtime.model_providers.base.tokenizers.gpt2_tokenizer import GPT2Tokenizer class EnhanceRecursiveCharacterTextSplitter(RecursiveCharacterTextSplitter): diff --git a/api/core/repositories/human_input_repository.py b/api/core/repositories/human_input_repository.py index 02625e242f..740d727e26 100644 --- a/api/core/repositories/human_input_repository.py +++ b/api/core/repositories/human_input_repository.py @@ -8,7 +8,7 @@ from sqlalchemy import select from sqlalchemy.orm import Session, selectinload from core.db.session_factory import session_factory -from core.workflow.human_input_compat import ( +from core.workflow.human_input_adapter import ( BoundRecipient, DeliveryChannelConfig, EmailDeliveryMethod, diff --git a/api/core/tools/tool_file_manager.py b/api/core/tools/tool_file_manager.py index b3424cd9a5..c87e8a3ae0 100644 --- a/api/core/tools/tool_file_manager.py +++ b/api/core/tools/tool_file_manager.py @@ -28,7 +28,7 @@ class ToolFileManager: def _build_graph_file_reference(tool_file: ToolFile) -> File: extension = guess_extension(tool_file.mimetype) or ".bin" return File( - type=get_file_type_by_mime_type(tool_file.mimetype), + file_type=get_file_type_by_mime_type(tool_file.mimetype), transfer_method=FileTransferMethod.TOOL_FILE, remote_url=tool_file.original_url, reference=build_file_reference(record_id=str(tool_file.id)), diff --git a/api/core/tools/tool_manager.py b/api/core/tools/tool_manager.py index f4588904d3..87cf6d7085 100644 --- a/api/core/tools/tool_manager.py +++ b/api/core/tools/tool_manager.py @@ -1082,7 +1082,12 @@ class ToolManager: continue tool_input = ToolNodeData.ToolInput.model_validate(tool_configurations.get(parameter.name, {})) if tool_input.type == "variable": - variable = variable_pool.get(tool_input.value) + variable_selector = tool_input.value + if not isinstance(variable_selector, list) or not all( + isinstance(selector_part, str) for selector_part in variable_selector + ): + raise ToolParameterError("Variable tool input must be a variable selector") + variable = variable_pool.get(variable_selector) if variable is None: raise ToolParameterError(f"Variable {tool_input.value} does not exist") parameter_value = variable.value diff --git a/api/core/tools/utils/model_invocation_utils.py b/api/core/tools/utils/model_invocation_utils.py index 9e1d41cb39..a3623d4ecd 100644 --- a/api/core/tools/utils/model_invocation_utils.py +++ b/api/core/tools/utils/model_invocation_utils.py @@ -21,7 +21,7 @@ from graphon.model_runtime.errors.invoke import ( InvokeRateLimitError, InvokeServerUnavailableError, ) -from graphon.model_runtime.model_providers.__base.large_language_model import LargeLanguageModel +from graphon.model_runtime.model_providers.base.large_language_model import LargeLanguageModel from graphon.model_runtime.utils.encoders import jsonable_encoder from models.tools import ToolModelInvoke diff --git a/api/core/tools/workflow_as_tool/tool.py b/api/core/tools/workflow_as_tool/tool.py index 52ab605963..cd8c6352b5 100644 --- a/api/core/tools/workflow_as_tool/tool.py +++ b/api/core/tools/workflow_as_tool/tool.py @@ -357,7 +357,10 @@ class WorkflowTool(Tool): def _update_file_mapping(self, file_dict: dict[str, Any]) -> dict[str, Any]: file_id = resolve_file_record_id(file_dict.get("reference") or file_dict.get("related_id")) - transfer_method = FileTransferMethod.value_of(file_dict.get("transfer_method")) + transfer_method_value = file_dict.get("transfer_method") + if not isinstance(transfer_method_value, str): + raise ValueError("Workflow file mapping is missing a valid transfer_method") + transfer_method = FileTransferMethod.value_of(transfer_method_value) match transfer_method: case FileTransferMethod.TOOL_FILE: file_dict["tool_file_id"] = file_id diff --git a/api/core/workflow/human_input_compat.py b/api/core/workflow/human_input_adapter.py similarity index 74% rename from api/core/workflow/human_input_compat.py rename to api/core/workflow/human_input_adapter.py index 75a0a0c202..4b765e6aea 100644 --- a/api/core/workflow/human_input_compat.py +++ b/api/core/workflow/human_input_adapter.py @@ -1,8 +1,8 @@ -"""Workflow-layer adapters for legacy human-input payload keys. +"""Workflow-to-Graphon adapters for persisted node payloads. -Stored workflow graphs and editor payloads may still use Dify-specific human -input recipient keys. Normalize them here before handing configs to -`graphon` so graph-owned models only see graph-neutral field names. +Stored workflow graphs and editor payloads still contain a small set of +Dify-owned field spellings and value shapes. Adapt them here before handing the +payload to Graphon so Graphon-owned models only see current contracts. """ from __future__ import annotations @@ -185,7 +185,7 @@ def _copy_mapping(value: object) -> dict[str, Any] | None: return None -def normalize_human_input_node_data_for_graph(node_data: Mapping[str, Any] | BaseModel) -> dict[str, Any]: +def adapt_human_input_node_data_for_graph(node_data: Mapping[str, Any] | BaseModel) -> dict[str, Any]: normalized = _copy_mapping(node_data) if normalized is None: raise TypeError(f"human-input node data must be a mapping, got {type(node_data).__name__}") @@ -215,7 +215,7 @@ def normalize_human_input_node_data_for_graph(node_data: Mapping[str, Any] | Bas def parse_human_input_delivery_methods(node_data: Mapping[str, Any] | BaseModel) -> list[DeliveryChannelConfig]: - normalized = normalize_human_input_node_data_for_graph(node_data) + normalized = adapt_human_input_node_data_for_graph(node_data) raw_delivery_methods = normalized.get("delivery_methods") if not isinstance(raw_delivery_methods, list): return [] @@ -229,17 +229,20 @@ def is_human_input_webapp_enabled(node_data: Mapping[str, Any] | BaseModel) -> b return False -def normalize_node_data_for_graph(node_data: Mapping[str, Any] | BaseModel) -> dict[str, Any]: +def adapt_node_data_for_graph(node_data: Mapping[str, Any] | BaseModel) -> dict[str, Any]: normalized = _copy_mapping(node_data) if normalized is None: raise TypeError(f"node data must be a mapping, got {type(node_data).__name__}") - if normalized.get("type") != BuiltinNodeTypes.HUMAN_INPUT: - return normalized - return normalize_human_input_node_data_for_graph(normalized) + node_type = normalized.get("type") + if node_type == BuiltinNodeTypes.HUMAN_INPUT: + return adapt_human_input_node_data_for_graph(normalized) + if node_type == BuiltinNodeTypes.TOOL: + return _adapt_tool_node_data_for_graph(normalized) + return normalized -def normalize_node_config_for_graph(node_config: Mapping[str, Any] | BaseModel) -> dict[str, Any]: +def adapt_node_config_for_graph(node_config: Mapping[str, Any] | BaseModel) -> dict[str, Any]: normalized = _copy_mapping(node_config) if normalized is None: raise TypeError(f"node config must be a mapping, got {type(node_config).__name__}") @@ -248,10 +251,65 @@ def normalize_node_config_for_graph(node_config: Mapping[str, Any] | BaseModel) if data_mapping is None: return normalized - normalized["data"] = normalize_node_data_for_graph(data_mapping) + normalized["data"] = adapt_node_data_for_graph(data_mapping) return normalized +def _adapt_tool_node_data_for_graph(node_data: Mapping[str, Any]) -> dict[str, Any]: + normalized = dict(node_data) + + raw_tool_configurations = normalized.get("tool_configurations") + if not isinstance(raw_tool_configurations, Mapping): + return normalized + + existing_tool_parameters = normalized.get("tool_parameters") + normalized_tool_parameters = dict(existing_tool_parameters) if isinstance(existing_tool_parameters, Mapping) else {} + normalized_tool_configurations: dict[str, Any] = {} + found_legacy_tool_inputs = False + + for name, value in raw_tool_configurations.items(): + if not isinstance(value, Mapping): + normalized_tool_configurations[name] = value + continue + + input_type = value.get("type") + input_value = value.get("value") + if input_type not in {"mixed", "variable", "constant"}: + normalized_tool_configurations[name] = value + continue + + found_legacy_tool_inputs = True + normalized_tool_parameters.setdefault(name, dict(value)) + + flattened_value = _flatten_legacy_tool_configuration_value( + input_type=input_type, + input_value=input_value, + ) + if flattened_value is not None: + normalized_tool_configurations[name] = flattened_value + + if not found_legacy_tool_inputs: + return normalized + + normalized["tool_parameters"] = normalized_tool_parameters + normalized["tool_configurations"] = normalized_tool_configurations + return normalized + + +def _flatten_legacy_tool_configuration_value(*, input_type: Any, input_value: Any) -> str | int | float | bool | None: + if input_type in {"mixed", "constant"} and isinstance(input_value, str | int | float | bool): + return input_value + + if ( + input_type == "variable" + and isinstance(input_value, list) + and all(isinstance(item, str) for item in input_value) + ): + return "{{#" + ".".join(input_value) + "#}}" + + return None + + def _normalize_email_recipients(recipients: Mapping[str, Any]) -> dict[str, Any]: normalized = dict(recipients) @@ -291,9 +349,9 @@ __all__ = [ "MemberRecipient", "WebAppDeliveryMethod", "_WebAppDeliveryConfig", + "adapt_human_input_node_data_for_graph", + "adapt_node_config_for_graph", + "adapt_node_data_for_graph", "is_human_input_webapp_enabled", - "normalize_human_input_node_data_for_graph", - "normalize_node_config_for_graph", - "normalize_node_data_for_graph", "parse_human_input_delivery_methods", ] diff --git a/api/core/workflow/node_factory.py b/api/core/workflow/node_factory.py index 351da3444f..de4eae1b22 100644 --- a/api/core/workflow/node_factory.py +++ b/api/core/workflow/node_factory.py @@ -15,12 +15,12 @@ from core.helper.code_executor.code_executor import ( CodeExecutionError, CodeExecutor, ) -from core.helper.ssrf_proxy import ssrf_proxy +from core.helper.ssrf_proxy import graphon_ssrf_proxy from core.memory.token_buffer_memory import TokenBufferMemory from core.model_manager import ModelInstance from core.prompt.entities.advanced_prompt_entities import MemoryConfig from core.trigger.constants import TRIGGER_NODE_TYPES -from core.workflow.human_input_compat import normalize_node_config_for_graph +from core.workflow.human_input_adapter import adapt_node_config_for_graph from core.workflow.node_runtime import ( DifyFileReferenceFactory, DifyHumanInputNodeRuntime, @@ -46,7 +46,7 @@ from graphon.enums import BuiltinNodeTypes, NodeType from graphon.file.file_manager import file_manager from graphon.graph.graph import NodeFactory from graphon.model_runtime.memory import PromptMessageMemory -from graphon.model_runtime.model_providers.__base.large_language_model import LargeLanguageModel +from graphon.model_runtime.model_providers.base.large_language_model import LargeLanguageModel from graphon.nodes.base.node import Node from graphon.nodes.code.code_node import WorkflowCodeExecutor from graphon.nodes.code.entities import CodeLanguage @@ -121,6 +121,7 @@ def get_node_type_classes_mapping() -> Mapping[NodeType, Mapping[str, type[Node] def resolve_workflow_node_class(*, node_type: NodeType, node_version: str) -> type[Node]: + """Resolve the production node class for the requested type/version.""" node_mapping = get_node_type_classes_mapping().get(node_type) if not node_mapping: raise ValueError(f"No class mapping found for node type: {node_type}") @@ -297,7 +298,7 @@ class DifyNodeFactory(NodeFactory): ) self._jinja2_template_renderer = CodeExecutorJinja2TemplateRenderer() self._template_transform_max_output_length = dify_config.TEMPLATE_TRANSFORM_MAX_LENGTH - self._http_request_http_client = ssrf_proxy + self._http_request_http_client = graphon_ssrf_proxy self._bound_tool_file_manager_factory = lambda: DifyToolFileManager( self._dify_context, conversation_id_getter=self._conversation_id, @@ -364,10 +365,14 @@ class DifyNodeFactory(NodeFactory): (including pydantic ValidationError, which subclasses ValueError), if node type is unknown, or if no implementation exists for the resolved version """ - typed_node_config = NodeConfigDictAdapter.validate_python(normalize_node_config_for_graph(node_config)) + typed_node_config = NodeConfigDictAdapter.validate_python(adapt_node_config_for_graph(node_config)) node_id = typed_node_config["id"] node_data = typed_node_config["data"] node_class = self._resolve_node_class(node_type=node_data.type, node_version=str(node_data.version)) + # Graph configs are initially validated against permissive shared node data. + # Re-validate using the resolved node class so workflow-local node schemas + # stay explicit and constructors receive the concrete typed payload. + resolved_node_data = self._validate_resolved_node_data(node_class, node_data) node_type = node_data.type node_init_kwargs_factories: Mapping[NodeType, Callable[[], dict[str, object]]] = { BuiltinNodeTypes.CODE: lambda: { @@ -391,7 +396,7 @@ class DifyNodeFactory(NodeFactory): }, BuiltinNodeTypes.LLM: lambda: self._build_llm_compatible_node_init_kwargs( node_class=node_class, - node_data=node_data, + node_data=resolved_node_data, wrap_model_instance=True, include_http_client=True, include_llm_file_saver=True, @@ -405,7 +410,7 @@ class DifyNodeFactory(NodeFactory): }, BuiltinNodeTypes.QUESTION_CLASSIFIER: lambda: self._build_llm_compatible_node_init_kwargs( node_class=node_class, - node_data=node_data, + node_data=resolved_node_data, wrap_model_instance=True, include_http_client=True, include_llm_file_saver=True, @@ -415,7 +420,7 @@ class DifyNodeFactory(NodeFactory): ), BuiltinNodeTypes.PARAMETER_EXTRACTOR: lambda: self._build_llm_compatible_node_init_kwargs( node_class=node_class, - node_data=node_data, + node_data=resolved_node_data, wrap_model_instance=True, include_http_client=False, include_llm_file_saver=False, @@ -436,8 +441,8 @@ class DifyNodeFactory(NodeFactory): } node_init_kwargs = node_init_kwargs_factories.get(node_type, lambda: {})() return node_class( - id=node_id, - config=typed_node_config, + node_id=node_id, + config=resolved_node_data, graph_init_params=self.graph_init_params, graph_runtime_state=self.graph_runtime_state, **node_init_kwargs, @@ -448,7 +453,10 @@ class DifyNodeFactory(NodeFactory): """ Re-validate the permissive graph payload with the concrete NodeData model declared by the resolved node class. """ - return node_class.validate_node_data(node_data) + validate_node_data = getattr(node_class, "validate_node_data", None) + if callable(validate_node_data): + return cast("BaseNodeData", validate_node_data(node_data)) + return node_data @staticmethod def _resolve_node_class(*, node_type: NodeType, node_version: str) -> type[Node]: diff --git a/api/core/workflow/node_runtime.py b/api/core/workflow/node_runtime.py index 2e632e56f0..b8725853c4 100644 --- a/api/core/workflow/node_runtime.py +++ b/api/core/workflow/node_runtime.py @@ -2,7 +2,7 @@ from __future__ import annotations from collections.abc import Callable, Generator, Mapping, Sequence from dataclasses import dataclass -from typing import TYPE_CHECKING, Any, cast +from typing import TYPE_CHECKING, Any, Literal, cast, overload from sqlalchemy import select from sqlalchemy.orm import Session @@ -41,7 +41,7 @@ from graphon.model_runtime.entities.llm_entities import ( ) from graphon.model_runtime.entities.message_entities import PromptMessage, PromptMessageTool from graphon.model_runtime.entities.model_entities import AIModelEntity -from graphon.model_runtime.model_providers.__base.large_language_model import LargeLanguageModel +from graphon.model_runtime.model_providers.base.large_language_model import LargeLanguageModel from graphon.nodes.human_input.entities import HumanInputNodeData from graphon.nodes.llm.runtime_protocols import ( PreparedLLMProtocol, @@ -64,7 +64,7 @@ from models.dataset import SegmentAttachmentBinding from models.model import UploadFile from services.tools.builtin_tools_manage_service import BuiltinToolManageService -from .human_input_compat import ( +from .human_input_adapter import ( BoundRecipient, DeliveryChannelConfig, DeliveryMethodType, @@ -173,6 +173,28 @@ class DifyPreparedLLM(PreparedLLMProtocol): def get_llm_num_tokens(self, prompt_messages: Sequence[PromptMessage]) -> int: return self._model_instance.get_llm_num_tokens(prompt_messages) + @overload + def invoke_llm( + self, + *, + prompt_messages: Sequence[PromptMessage], + model_parameters: Mapping[str, Any], + tools: Sequence[PromptMessageTool] | None, + stop: Sequence[str] | None, + stream: Literal[False], + ) -> LLMResult: ... + + @overload + def invoke_llm( + self, + *, + prompt_messages: Sequence[PromptMessage], + model_parameters: Mapping[str, Any], + tools: Sequence[PromptMessageTool] | None, + stop: Sequence[str] | None, + stream: Literal[True], + ) -> Generator[LLMResultChunk, None, None]: ... + def invoke_llm( self, *, @@ -190,6 +212,28 @@ class DifyPreparedLLM(PreparedLLMProtocol): stream=stream, ) + @overload + def invoke_llm_with_structured_output( + self, + *, + prompt_messages: Sequence[PromptMessage], + json_schema: Mapping[str, Any], + model_parameters: Mapping[str, Any], + stop: Sequence[str] | None, + stream: Literal[False], + ) -> LLMResultWithStructuredOutput: ... + + @overload + def invoke_llm_with_structured_output( + self, + *, + prompt_messages: Sequence[PromptMessage], + json_schema: Mapping[str, Any], + model_parameters: Mapping[str, Any], + stop: Sequence[str] | None, + stream: Literal[True], + ) -> Generator[LLMResultChunkWithStructuredOutput, None, None]: ... + def invoke_llm_with_structured_output( self, *, diff --git a/api/core/workflow/nodes/agent/agent_node.py b/api/core/workflow/nodes/agent/agent_node.py index 7b000101b0..68a24e86b1 100644 --- a/api/core/workflow/nodes/agent/agent_node.py +++ b/api/core/workflow/nodes/agent/agent_node.py @@ -5,7 +5,6 @@ from typing import TYPE_CHECKING, Any from core.app.entities.app_invoke_entities import DIFY_RUN_CONTEXT_KEY, DifyRunContext from core.workflow.system_variables import SystemVariableKey, get_system_text -from graphon.entities.graph_config import NodeConfigDict from graphon.enums import BuiltinNodeTypes, WorkflowNodeExecutionStatus from graphon.node_events import NodeEventBase, NodeRunResult, StreamCompletedEvent from graphon.nodes.base.node import Node @@ -35,18 +34,18 @@ class AgentNode(Node[AgentNodeData]): def __init__( self, - id: str, - config: NodeConfigDict, + node_id: str, + config: AgentNodeData, + *, graph_init_params: GraphInitParams, graph_runtime_state: GraphRuntimeState, - *, strategy_resolver: AgentStrategyResolver, presentation_provider: AgentStrategyPresentationProvider, runtime_support: AgentRuntimeSupport, message_transformer: AgentMessageTransformer, ) -> None: super().__init__( - id=id, + node_id=node_id, config=config, graph_init_params=graph_init_params, graph_runtime_state=graph_runtime_state, diff --git a/api/core/workflow/nodes/datasource/datasource_node.py b/api/core/workflow/nodes/datasource/datasource_node.py index e4f6b3b470..f3006c4242 100644 --- a/api/core/workflow/nodes/datasource/datasource_node.py +++ b/api/core/workflow/nodes/datasource/datasource_node.py @@ -7,7 +7,6 @@ from core.datasource.entities.datasource_entities import DatasourceProviderType from core.plugin.impl.exc import PluginDaemonClientSideError from core.workflow.file_reference import resolve_file_record_id from core.workflow.system_variables import SystemVariableKey, get_system_segment -from graphon.entities.graph_config import NodeConfigDict from graphon.enums import ( BuiltinNodeTypes, NodeExecutionType, @@ -36,13 +35,14 @@ class DatasourceNode(Node[DatasourceNodeData]): def __init__( self, - id: str, - config: NodeConfigDict, + node_id: str, + config: DatasourceNodeData, + *, graph_init_params: "GraphInitParams", graph_runtime_state: "GraphRuntimeState", - ): + ) -> None: super().__init__( - id=id, + node_id=node_id, config=config, graph_init_params=graph_init_params, graph_runtime_state=graph_runtime_state, diff --git a/api/core/workflow/nodes/knowledge_index/knowledge_index_node.py b/api/core/workflow/nodes/knowledge_index/knowledge_index_node.py index d5cab05dbe..9c1b7ab2c4 100644 --- a/api/core/workflow/nodes/knowledge_index/knowledge_index_node.py +++ b/api/core/workflow/nodes/knowledge_index/knowledge_index_node.py @@ -7,7 +7,6 @@ from core.rag.index_processor.index_processor_base import SummaryIndexSettingDic from core.rag.summary_index.summary_index import SummaryIndex from core.workflow.nodes.knowledge_index import KNOWLEDGE_INDEX_NODE_TYPE from core.workflow.system_variables import SystemVariableKey, get_system_segment, get_system_text -from graphon.entities.graph_config import NodeConfigDict from graphon.enums import NodeExecutionType, WorkflowNodeExecutionStatus from graphon.node_events import NodeRunResult from graphon.nodes.base.node import Node @@ -32,12 +31,18 @@ class KnowledgeIndexNode(Node[KnowledgeIndexNodeData]): def __init__( self, - id: str, - config: NodeConfigDict, + node_id: str, + config: KnowledgeIndexNodeData, + *, graph_init_params: "GraphInitParams", graph_runtime_state: "GraphRuntimeState", ) -> None: - super().__init__(id, config, graph_init_params, graph_runtime_state) + super().__init__( + node_id=node_id, + config=config, + graph_init_params=graph_init_params, + graph_runtime_state=graph_runtime_state, + ) self.index_processor = IndexProcessor() self.summary_index_service = SummaryIndex() diff --git a/api/core/workflow/nodes/knowledge_retrieval/knowledge_retrieval_node.py b/api/core/workflow/nodes/knowledge_retrieval/knowledge_retrieval_node.py index 47ad14b499..25f73e446d 100644 --- a/api/core/workflow/nodes/knowledge_retrieval/knowledge_retrieval_node.py +++ b/api/core/workflow/nodes/knowledge_retrieval/knowledge_retrieval_node.py @@ -14,7 +14,6 @@ from core.rag.data_post_processor.data_post_processor import RerankingModelDict, from core.rag.retrieval.dataset_retrieval import DatasetRetrieval from core.workflow.file_reference import parse_file_reference from graphon.entities import GraphInitParams -from graphon.entities.graph_config import NodeConfigDict from graphon.enums import ( BuiltinNodeTypes, WorkflowNodeExecutionMetadataKey, @@ -50,6 +49,18 @@ if TYPE_CHECKING: logger = logging.getLogger(__name__) +def _normalize_metadata_filter_scalar(value: object) -> str | int | float | None: + if value is None or isinstance(value, (str, float)): + return value + if isinstance(value, int) and not isinstance(value, bool): + return value + return str(value) + + +def _normalize_metadata_filter_sequence_item(value: object) -> str: + return value if isinstance(value, str) else str(value) + + class KnowledgeRetrievalNode(LLMUsageTrackingMixin, Node[KnowledgeRetrievalNodeData]): node_type = BuiltinNodeTypes.KNOWLEDGE_RETRIEVAL @@ -59,13 +70,14 @@ class KnowledgeRetrievalNode(LLMUsageTrackingMixin, Node[KnowledgeRetrievalNodeD def __init__( self, - id: str, - config: NodeConfigDict, + node_id: str, + config: KnowledgeRetrievalNodeData, + *, graph_init_params: "GraphInitParams", graph_runtime_state: "GraphRuntimeState", - ): + ) -> None: super().__init__( - id=id, + node_id=node_id, config=config, graph_init_params=graph_init_params, graph_runtime_state=graph_runtime_state, @@ -282,18 +294,21 @@ class KnowledgeRetrievalNode(LLMUsageTrackingMixin, Node[KnowledgeRetrievalNodeD resolved_conditions: list[Condition] = [] for cond in conditions.conditions or []: value = cond.value + resolved_value: str | Sequence[str] | int | float | None if isinstance(value, str): segment_group = variable_pool.convert_template(value) if len(segment_group.value) == 1: - resolved_value = segment_group.value[0].to_object() + resolved_value = _normalize_metadata_filter_scalar(segment_group.value[0].to_object()) else: resolved_value = segment_group.text elif isinstance(value, Sequence) and all(isinstance(v, str) for v in value): - resolved_values = [] - for v in value: # type: ignore + resolved_values: list[str] = [] + for v in value: segment_group = variable_pool.convert_template(v) if len(segment_group.value) == 1: - resolved_values.append(segment_group.value[0].to_object()) + resolved_values.append( + _normalize_metadata_filter_sequence_item(segment_group.value[0].to_object()) + ) else: resolved_values.append(segment_group.text) resolved_value = resolved_values diff --git a/api/factories/file_factory/builders.py b/api/factories/file_factory/builders.py index ce1fa441c2..1d2ad4d445 100644 --- a/api/factories/file_factory/builders.py +++ b/api/factories/file_factory/builders.py @@ -148,11 +148,11 @@ def _build_from_local_file( ) return File( - id=mapping.get("id"), + file_id=mapping.get("id"), filename=row.name, extension="." + row.extension, mime_type=row.mime_type, - type=file_type, + file_type=file_type, transfer_method=transfer_method, remote_url=row.source_url, reference=build_file_reference(record_id=str(row.id)), @@ -196,11 +196,11 @@ def _build_from_remote_url( ) return File( - id=mapping.get("id"), + file_id=mapping.get("id"), filename=upload_file.name, extension="." + upload_file.extension, mime_type=upload_file.mime_type, - type=file_type, + file_type=file_type, transfer_method=transfer_method, remote_url=helpers.get_signed_file_url(upload_file_id=str(upload_file_id)), reference=build_file_reference(record_id=str(upload_file.id)), @@ -222,9 +222,9 @@ def _build_from_remote_url( ) return File( - id=mapping.get("id"), + file_id=mapping.get("id"), filename=filename, - type=file_type, + file_type=file_type, transfer_method=transfer_method, remote_url=url, mime_type=mime_type, @@ -263,9 +263,9 @@ def _build_from_tool_file( ) return File( - id=mapping.get("id"), + file_id=mapping.get("id"), filename=tool_file.name, - type=file_type, + file_type=file_type, transfer_method=transfer_method, remote_url=tool_file.original_url, reference=build_file_reference(record_id=str(tool_file.id)), @@ -306,9 +306,9 @@ def _build_from_datasource_file( ) return File( - id=mapping.get("datasource_file_id"), + file_id=mapping.get("datasource_file_id"), filename=datasource_file.name, - type=file_type, + file_type=file_type, transfer_method=FileTransferMethod.TOOL_FILE, remote_url=datasource_file.source_url, reference=build_file_reference(record_id=str(datasource_file.id)), diff --git a/api/fields/_value_type_serializer.py b/api/fields/_value_type_serializer.py index b5acbbbcb4..d518114777 100644 --- a/api/fields/_value_type_serializer.py +++ b/api/fields/_value_type_serializer.py @@ -10,9 +10,9 @@ class _VarTypedDict(TypedDict, total=False): def serialize_value_type(v: _VarTypedDict | Segment) -> str: if isinstance(v, Segment): - return v.value_type.exposed_type().value + return str(v.value_type.exposed_type()) else: value_type = v.get("value_type") if value_type is None: raise ValueError("value_type is required but not provided") - return value_type.exposed_type().value + return str(value_type.exposed_type()) diff --git a/api/fields/conversation_variable_fields.py b/api/fields/conversation_variable_fields.py index cf4a71d545..e4219ba1ee 100644 --- a/api/fields/conversation_variable_fields.py +++ b/api/fields/conversation_variable_fields.py @@ -57,10 +57,10 @@ class ConversationVariableResponse(ResponseModel): def _normalize_value_type(cls, value: Any) -> str: exposed_type = getattr(value, "exposed_type", None) if callable(exposed_type): - return str(exposed_type().value) + return str(exposed_type()) if isinstance(value, str): try: - return str(SegmentType(value).exposed_type().value) + return str(SegmentType(value).exposed_type()) except ValueError: return value try: diff --git a/api/fields/workflow_fields.py b/api/fields/workflow_fields.py index f9b5e98936..6e947858ba 100644 --- a/api/fields/workflow_fields.py +++ b/api/fields/workflow_fields.py @@ -26,7 +26,7 @@ class EnvironmentVariableField(fields.Raw): "id": value.id, "name": value.name, "value": value.value, - "value_type": value.value_type.exposed_type().value, + "value_type": str(value.value_type.exposed_type()), "description": value.description, } if isinstance(value, dict): diff --git a/api/models/human_input.py b/api/models/human_input.py index b4c7a634b6..7447d3efcb 100644 --- a/api/models/human_input.py +++ b/api/models/human_input.py @@ -6,7 +6,7 @@ import sqlalchemy as sa from pydantic import BaseModel, Field from sqlalchemy.orm import Mapped, mapped_column, relationship -from core.workflow.human_input_compat import DeliveryMethodType +from core.workflow.human_input_adapter import DeliveryMethodType from graphon.nodes.human_input.enums import HumanInputFormKind, HumanInputFormStatus from libs.helper import generate_string diff --git a/api/models/utils/file_input_compat.py b/api/models/utils/file_input_compat.py index a2dc8f6157..77dcbd13d4 100644 --- a/api/models/utils/file_input_compat.py +++ b/api/models/utils/file_input_compat.py @@ -5,7 +5,8 @@ from functools import lru_cache from typing import Any from core.workflow.file_reference import parse_file_reference -from graphon.file import File, FileTransferMethod +from graphon.file import File, FileTransferMethod, FileType +from graphon.file.constants import FILE_MODEL_IDENTITY, maybe_file_object @lru_cache(maxsize=1) @@ -43,6 +44,124 @@ def resolve_file_mapping_tenant_id( return tenant_resolver() +def build_file_from_mapping_without_lookup(*, file_mapping: Mapping[str, Any]) -> File: + """Build a graph `File` directly from serialized metadata.""" + + def _coerce_file_type(value: Any) -> FileType: + if isinstance(value, FileType): + return value + if isinstance(value, str): + return FileType.value_of(value) + raise ValueError("file type is required in file mapping") + + mapping = dict(file_mapping) + transfer_method_value = mapping.get("transfer_method") + if isinstance(transfer_method_value, FileTransferMethod): + transfer_method = transfer_method_value + elif isinstance(transfer_method_value, str): + transfer_method = FileTransferMethod.value_of(transfer_method_value) + else: + raise ValueError("transfer_method is required in file mapping") + + file_id = mapping.get("file_id") + if not isinstance(file_id, str) or not file_id: + legacy_id = mapping.get("id") + file_id = legacy_id if isinstance(legacy_id, str) and legacy_id else None + + related_id = resolve_file_record_id(mapping) + if related_id is None: + raw_related_id = mapping.get("related_id") + related_id = raw_related_id if isinstance(raw_related_id, str) and raw_related_id else None + + remote_url = mapping.get("remote_url") + if not isinstance(remote_url, str) or not remote_url: + url = mapping.get("url") + remote_url = url if isinstance(url, str) and url else None + + reference = mapping.get("reference") + if not isinstance(reference, str) or not reference: + reference = None + + filename = mapping.get("filename") + if not isinstance(filename, str): + filename = None + + extension = mapping.get("extension") + if not isinstance(extension, str): + extension = None + + mime_type = mapping.get("mime_type") + if not isinstance(mime_type, str): + mime_type = None + + size = mapping.get("size", -1) + if not isinstance(size, int): + size = -1 + + storage_key = mapping.get("storage_key") + if not isinstance(storage_key, str): + storage_key = None + + tenant_id = mapping.get("tenant_id") + if not isinstance(tenant_id, str): + tenant_id = None + + dify_model_identity = mapping.get("dify_model_identity") + if not isinstance(dify_model_identity, str): + dify_model_identity = FILE_MODEL_IDENTITY + + tool_file_id = mapping.get("tool_file_id") + if not isinstance(tool_file_id, str): + tool_file_id = None + + upload_file_id = mapping.get("upload_file_id") + if not isinstance(upload_file_id, str): + upload_file_id = None + + datasource_file_id = mapping.get("datasource_file_id") + if not isinstance(datasource_file_id, str): + datasource_file_id = None + + return File( + file_id=file_id, + tenant_id=tenant_id, + file_type=_coerce_file_type(mapping.get("file_type", mapping.get("type"))), + transfer_method=transfer_method, + remote_url=remote_url, + reference=reference, + related_id=related_id, + filename=filename, + extension=extension, + mime_type=mime_type, + size=size, + storage_key=storage_key, + dify_model_identity=dify_model_identity, + url=remote_url, + tool_file_id=tool_file_id, + upload_file_id=upload_file_id, + datasource_file_id=datasource_file_id, + ) + + +def rebuild_serialized_graph_files_without_lookup(value: Any) -> Any: + """Recursively rebuild serialized graph file payloads into `File` objects. + + `graphon` 0.2.2 no longer accepts legacy serialized file mappings via + `model_validate_json()`. Dify keeps this recovery path at the model boundary + so historical JSON blobs remain readable without reintroducing global graph + patches or test-local coercion. + """ + if isinstance(value, list): + return [rebuild_serialized_graph_files_without_lookup(item) for item in value] + + if isinstance(value, dict): + if maybe_file_object(value): + return build_file_from_mapping_without_lookup(file_mapping=value) + return {key: rebuild_serialized_graph_files_without_lookup(item) for key, item in value.items()} + + return value + + def build_file_from_stored_mapping( *, file_mapping: Mapping[str, Any], @@ -76,12 +195,7 @@ def build_file_from_stored_mapping( pass if transfer_method == FileTransferMethod.REMOTE_URL and record_id is None: - remote_url = mapping.get("remote_url") - if not isinstance(remote_url, str) or not remote_url: - url = mapping.get("url") - if isinstance(url, str) and url: - mapping["remote_url"] = url - return File.model_validate(mapping) + return build_file_from_mapping_without_lookup(file_mapping=mapping) return file_factory.build_from_mapping( mapping=mapping, diff --git a/api/models/workflow.py b/api/models/workflow.py index dfda03c2ee..d127244b0f 100644 --- a/api/models/workflow.py +++ b/api/models/workflow.py @@ -24,7 +24,7 @@ from sqlalchemy.orm import Mapped, mapped_column from typing_extensions import deprecated from core.trigger.constants import TRIGGER_PLUGIN_NODE_TYPE -from core.workflow.human_input_compat import normalize_node_config_for_graph +from core.workflow.human_input_adapter import adapt_node_config_for_graph from core.workflow.variable_prefixes import ( CONVERSATION_VARIABLE_NODE_ID, SYSTEM_VARIABLE_NODE_ID, @@ -64,7 +64,10 @@ from .base import Base, DefaultFieldsDCMixin, TypeBase from .engine import db from .enums import CreatorUserRole, DraftVariableType, ExecutionOffLoadType, WorkflowRunTriggeredFrom from .types import EnumText, LongText, StringUUID -from .utils.file_input_compat import build_file_from_stored_mapping +from .utils.file_input_compat import ( + build_file_from_mapping_without_lookup, + build_file_from_stored_mapping, +) logger = logging.getLogger(__name__) @@ -290,7 +293,7 @@ class Workflow(Base): # bug node_config: dict[str, Any] = next(filter(lambda node: node["id"] == node_id, nodes)) except StopIteration: raise NodeNotFoundError(node_id) - return NodeConfigDictAdapter.validate_python(normalize_node_config_for_graph(node_config)) + return NodeConfigDictAdapter.validate_python(adapt_node_config_for_graph(node_config)) @staticmethod def get_node_type_from_node_config(node_config: NodeConfigDict) -> NodeType: @@ -1688,7 +1691,7 @@ class WorkflowDraftVariable(Base): return cast(Any, value) normalized_file = dict(value) normalized_file.pop("tenant_id", None) - return File.model_validate(normalized_file) + return build_file_from_mapping_without_lookup(file_mapping=normalized_file) elif isinstance(value, list) and value: value_list = cast(list[Any], value) first: Any = value_list[0] @@ -1698,7 +1701,7 @@ class WorkflowDraftVariable(Base): for item in value_list: normalized_file = dict(cast(dict[str, Any], item)) normalized_file.pop("tenant_id", None) - file_list.append(File.model_validate(normalized_file)) + file_list.append(build_file_from_mapping_without_lookup(file_mapping=normalized_file)) return cast(Any, file_list) else: return cast(Any, value) diff --git a/api/providers/trace/trace-tencent/src/dify_trace_tencent/tencent_trace.py b/api/providers/trace/trace-tencent/src/dify_trace_tencent/tencent_trace.py index cfcf6b307e..a8c480e4a5 100644 --- a/api/providers/trace/trace-tencent/src/dify_trace_tencent/tencent_trace.py +++ b/api/providers/trace/trace-tencent/src/dify_trace_tencent/tencent_trace.py @@ -1,7 +1,6 @@ -""" -Tencent APM tracing implementation with separated concerns -""" +"""Tencent APM tracing with idempotent client cleanup.""" +import inspect import logging from sqlalchemy import select @@ -38,10 +37,18 @@ class TencentDataTrace(BaseTraceInstance): """ Tencent APM trace implementation with single responsibility principle. Acts as a coordinator that delegates specific tasks to specialized classes. + + The instance owns a long-lived ``TencentTraceClient``. Cleanup may happen + explicitly in tests or implicitly during garbage collection, so shutdown + must be safe to call multiple times. """ + trace_client: TencentTraceClient + _closed: bool + def __init__(self, tencent_config: TencentConfig): super().__init__(tencent_config) + self._closed = False self.trace_client = TencentTraceClient( service_name=tencent_config.service_name, endpoint=tencent_config.endpoint, @@ -513,10 +520,25 @@ class TencentDataTrace(BaseTraceInstance): except Exception: logger.debug("[Tencent APM] Failed to record message trace duration") - def __del__(self): - """Ensure proper cleanup on garbage collection.""" + def close(self) -> None: + """Synchronously and idempotently shutdown the underlying trace client.""" + if getattr(self, "_closed", False): + return + + self._closed = True + trace_client = getattr(self, "trace_client", None) + if trace_client is None: + return + try: - if hasattr(self, "trace_client"): - self.trace_client.shutdown() + shutdown_result = trace_client.shutdown() + if inspect.isawaitable(shutdown_result): + close_awaitable = getattr(shutdown_result, "close", None) + if callable(close_awaitable): + close_awaitable() except Exception: logger.exception("[Tencent APM] Failed to shutdown trace client during cleanup") + + def __del__(self): + """Ensure best-effort cleanup on garbage collection without retrying shutdown.""" + self.close() diff --git a/api/providers/trace/trace-tencent/tests/unit_tests/tencent_trace/test_tencent_trace.py b/api/providers/trace/trace-tencent/tests/unit_tests/tencent_trace/test_tencent_trace.py index a91a0aa558..54524b09ca 100644 --- a/api/providers/trace/trace-tencent/tests/unit_tests/tencent_trace/test_tencent_trace.py +++ b/api/providers/trace/trace-tencent/tests/unit_tests/tencent_trace/test_tencent_trace.py @@ -1,5 +1,7 @@ +import gc import logging -from unittest.mock import MagicMock, patch +import warnings +from unittest.mock import AsyncMock, MagicMock, patch import pytest from dify_trace_tencent.config import TencentConfig @@ -632,13 +634,38 @@ class TestTencentDataTrace: with patch("dify_trace_tencent.tencent_trace.logger.debug") as mock_log: tencent_data_trace._record_message_trace_duration(trace_info) - def test_del(self, tencent_data_trace): + def test_close(self, tencent_data_trace): client = tencent_data_trace.trace_client - tencent_data_trace.__del__() + tencent_data_trace.close() client.shutdown.assert_called_once() - def test_del_exception(self, tencent_data_trace): + def test_close_is_idempotent(self, tencent_data_trace): + client = tencent_data_trace.trace_client + + tencent_data_trace.close() + tencent_data_trace.close() + + client.shutdown.assert_called_once() + + def test_close_exception(self, tencent_data_trace): tencent_data_trace.trace_client.shutdown.side_effect = Exception("error") with patch("dify_trace_tencent.tencent_trace.logger.exception") as mock_log: - tencent_data_trace.__del__() + tencent_data_trace.close() mock_log.assert_called_once_with("[Tencent APM] Failed to shutdown trace client during cleanup") + + def test_close_handles_async_shutdown_mock(self, tencent_data_trace): + shutdown = AsyncMock() + tencent_data_trace.trace_client.shutdown = shutdown + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + tencent_data_trace.close() + gc.collect() + + shutdown.assert_called_once() + assert not [ + warning + for warning in caught + if issubclass(warning.category, RuntimeWarning) + and "AsyncMockMixin._execute_mock_call" in str(warning.message) + ] diff --git a/api/pyproject.toml b/api/pyproject.toml index 12b8b3d782..8f6ee796ab 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -44,7 +44,7 @@ dependencies = [ # Emerging: newer and fast-moving, use compatible pins "fastopenapi[flask]~=0.7.0", - "graphon~=0.1.2", + "graphon~=0.2.2", "httpx-sse~=0.4.0", "json-repair~=0.59.2", ] diff --git a/api/services/app_service.py b/api/services/app_service.py index afd98e2975..038c59633a 100644 --- a/api/services/app_service.py +++ b/api/services/app_service.py @@ -16,7 +16,7 @@ from core.tools.utils.configuration import ToolParameterConfigurationManager from events.app_event import app_was_created, app_was_deleted, app_was_updated from extensions.ext_database import db from graphon.model_runtime.entities.model_entities import ModelPropertyKey, ModelType -from graphon.model_runtime.model_providers.__base.large_language_model import LargeLanguageModel +from graphon.model_runtime.model_providers.base.large_language_model import LargeLanguageModel from libs.datetime_utils import naive_utc_now from libs.login import current_user from models import Account diff --git a/api/services/dataset_service.py b/api/services/dataset_service.py index e6f5f80a6d..894cb05687 100644 --- a/api/services/dataset_service.py +++ b/api/services/dataset_service.py @@ -30,7 +30,7 @@ from extensions.ext_database import db from extensions.ext_redis import redis_client from graphon.file import helpers as file_helpers from graphon.model_runtime.entities.model_entities import ModelFeature, ModelType -from graphon.model_runtime.model_providers.__base.text_embedding_model import TextEmbeddingModel +from graphon.model_runtime.model_providers.base.text_embedding_model import TextEmbeddingModel from libs import helper from libs.datetime_utils import naive_utc_now from libs.login import current_user diff --git a/api/services/human_input_delivery_test_service.py b/api/services/human_input_delivery_test_service.py index 68ef67dec1..8b4983e5f7 100644 --- a/api/services/human_input_delivery_test_service.py +++ b/api/services/human_input_delivery_test_service.py @@ -8,7 +8,7 @@ from sqlalchemy import Engine, select from sqlalchemy.orm import sessionmaker from configs import dify_config -from core.workflow.human_input_compat import ( +from core.workflow.human_input_adapter import ( DeliveryChannelConfig, EmailDeliveryConfig, EmailDeliveryMethod, diff --git a/api/services/rag_pipeline/rag_pipeline.py b/api/services/rag_pipeline/rag_pipeline.py index 968600d1bc..9db6682e10 100644 --- a/api/services/rag_pipeline/rag_pipeline.py +++ b/api/services/rag_pipeline/rag_pipeline.py @@ -476,7 +476,7 @@ class RagPipelineService: :param filters: filter by node config parameters. :return: """ - node_type_enum = NodeType(node_type) + node_type_enum: NodeType = node_type node_mapping = get_node_type_classes_mapping() # return default block config diff --git a/api/services/variable_truncator.py b/api/services/variable_truncator.py index c96050ce13..1529c2b98f 100644 --- a/api/services/variable_truncator.py +++ b/api/services/variable_truncator.py @@ -169,7 +169,7 @@ class VariableTruncator(BaseTruncator): return TruncationResult(StringSegment(value=fallback_result.value), True) # Apply final fallback - convert to JSON string and truncate - json_str = dumps_with_segments(result.value, ensure_ascii=False) + json_str = dumps_with_segments(result.value) if len(json_str) > self._max_size_bytes: json_str = json_str[: self._max_size_bytes] + "..." return TruncationResult(result=StringSegment(value=json_str), truncated=True) diff --git a/api/services/workflow_draft_variable_service.py b/api/services/workflow_draft_variable_service.py index 5ec00ee336..96f936ff9b 100644 --- a/api/services/workflow_draft_variable_service.py +++ b/api/services/workflow_draft_variable_service.py @@ -146,7 +146,7 @@ class DraftVarLoader(VariableLoader): variable = segment_to_variable( segment=segment, selector=draft_var.get_selector(), - id=draft_var.id, + variable_id=draft_var.id, name=draft_var.name, description=draft_var.description, ) @@ -180,7 +180,7 @@ class DraftVarLoader(VariableLoader): variable = segment_to_variable( segment=segment, selector=draft_var.get_selector(), - id=draft_var.id, + variable_id=draft_var.id, name=draft_var.name, description=draft_var.description, ) @@ -191,7 +191,7 @@ class DraftVarLoader(VariableLoader): variable = segment_to_variable( segment=segment, selector=draft_var.get_selector(), - id=draft_var.id, + variable_id=draft_var.id, name=draft_var.name, description=draft_var.description, ) @@ -1067,7 +1067,7 @@ class DraftVariableSaver: filename = f"{self._generate_filename(name)}.txt" else: # For other types, store as JSON - original_content_serialized = dumps_with_segments(value_seg.value, ensure_ascii=False) + original_content_serialized = dumps_with_segments(value_seg.value) content_type = "application/json" filename = f"{self._generate_filename(name)}.json" diff --git a/api/services/workflow_service.py b/api/services/workflow_service.py index d71223314e..d4b9095ce5 100644 --- a/api/services/workflow_service.py +++ b/api/services/workflow_service.py @@ -18,9 +18,9 @@ from core.plugin.impl.model_runtime_factory import create_plugin_model_assembly, from core.repositories import DifyCoreRepositoryFactory from core.repositories.human_input_repository import FormCreateParams, HumanInputFormRepositoryImpl from core.trigger.constants import is_trigger_node_type -from core.workflow.human_input_compat import ( +from core.workflow.human_input_adapter import ( DeliveryChannelConfig, - normalize_human_input_node_data_for_graph, + adapt_human_input_node_data_for_graph, parse_human_input_delivery_methods, ) from core.workflow.node_factory import ( @@ -791,7 +791,7 @@ class WorkflowService: :param filters: filter by node config parameters. :return: """ - node_type_enum = NodeType(node_type) + node_type_enum: NodeType = node_type node_mapping = get_node_type_classes_mapping() # return default block config @@ -1096,7 +1096,7 @@ class WorkflowService: raise ValueError("Node type must be human-input.") node_data = HumanInputNodeData.model_validate( - normalize_human_input_node_data_for_graph(node_config["data"]), + adapt_human_input_node_data_for_graph(node_config["data"]), from_attributes=True, ) delivery_method = self._resolve_human_input_delivery_method( @@ -1237,9 +1237,10 @@ class WorkflowService: variable_pool=variable_pool, start_at=time.perf_counter(), ) + node_data = HumanInputNode.validate_node_data(adapt_human_input_node_data_for_graph(node_config["data"])) node = HumanInputNode( - id=node_config["id"], - config=node_config, + node_id=node_config["id"], + config=node_data, graph_init_params=graph_init_params, graph_runtime_state=graph_runtime_state, runtime=DifyHumanInputNodeRuntime(run_context), @@ -1529,7 +1530,7 @@ class WorkflowService: from graphon.nodes.human_input.entities import HumanInputNodeData try: - HumanInputNodeData.model_validate(normalize_human_input_node_data_for_graph(node_data)) + HumanInputNodeData.model_validate(adapt_human_input_node_data_for_graph(node_data)) except Exception as e: raise ValueError(f"Invalid HumanInput node data: {str(e)}") diff --git a/api/tasks/mail_human_input_delivery_task.py b/api/tasks/mail_human_input_delivery_task.py index f8ae3f4b6e..2a60be7762 100644 --- a/api/tasks/mail_human_input_delivery_task.py +++ b/api/tasks/mail_human_input_delivery_task.py @@ -11,7 +11,7 @@ from sqlalchemy.orm import Session, sessionmaker from configs import dify_config from core.app.layers.pause_state_persist_layer import WorkflowResumptionContext -from core.workflow.human_input_compat import EmailDeliveryConfig, EmailDeliveryMethod +from core.workflow.human_input_adapter import EmailDeliveryConfig, EmailDeliveryMethod from extensions.ext_database import db from extensions.ext_mail import mail from graphon.runtime import GraphRuntimeState, VariablePool diff --git a/api/tests/integration_tests/core/workflow/nodes/datasource/test_datasource_node_integration.py b/api/tests/integration_tests/core/workflow/nodes/datasource/test_datasource_node_integration.py index b5318aaa2b..2392084c36 100644 --- a/api/tests/integration_tests/core/workflow/nodes/datasource/test_datasource_node_integration.py +++ b/api/tests/integration_tests/core/workflow/nodes/datasource/test_datasource_node_integration.py @@ -1,5 +1,6 @@ from core.app.entities.app_invoke_entities import DIFY_RUN_CONTEXT_KEY from core.workflow.nodes.datasource.datasource_node import DatasourceNode +from core.workflow.nodes.datasource.entities import DatasourceNodeData from graphon.enums import WorkflowNodeExecutionStatus from graphon.node_events import NodeRunResult, StreamCompletedEvent @@ -69,19 +70,16 @@ def test_node_integration_minimal_stream(mocker): mocker.patch("core.workflow.nodes.datasource.datasource_node.DatasourceManager", new=_Mgr) node = DatasourceNode( - id="n", - config={ - "id": "n", - "data": { - "type": "datasource", - "version": "1", - "title": "Datasource", - "provider_type": "plugin", - "provider_name": "p", - "plugin_id": "plug", - "datasource_name": "ds", - }, - }, + node_id="n", + config=DatasourceNodeData( + type="datasource", + version="1", + title="Datasource", + provider_type="plugin", + provider_name="p", + plugin_id="plug", + datasource_name="ds", + ), graph_init_params=_GP(), graph_runtime_state=_GS(vp), ) diff --git a/api/tests/integration_tests/workflow/nodes/test_code.py b/api/tests/integration_tests/workflow/nodes/test_code.py index e3476c292b..aaa6092993 100644 --- a/api/tests/integration_tests/workflow/nodes/test_code.py +++ b/api/tests/integration_tests/workflow/nodes/test_code.py @@ -11,6 +11,7 @@ from graphon.enums import WorkflowNodeExecutionStatus from graphon.graph import Graph from graphon.node_events import NodeRunResult from graphon.nodes.code.code_node import CodeNode +from graphon.nodes.code.entities import CodeNodeData from graphon.nodes.code.limits import CodeNodeLimits from graphon.runtime import GraphRuntimeState, VariablePool from tests.workflow_test_utils import build_test_graph_init_params @@ -64,8 +65,8 @@ def init_code_node(code_config: dict): graph = Graph.init(graph_config=graph_config, node_factory=node_factory, root_node_id="start") node = CodeNode( - id=str(uuid.uuid4()), - config=code_config, + node_id=str(uuid.uuid4()), + config=CodeNodeData.model_validate(code_config["data"]), graph_init_params=init_params, graph_runtime_state=graph_runtime_state, code_executor=node_factory._code_executor, diff --git a/api/tests/integration_tests/workflow/nodes/test_http.py b/api/tests/integration_tests/workflow/nodes/test_http.py index aa6cf1e021..b9f7b9575b 100644 --- a/api/tests/integration_tests/workflow/nodes/test_http.py +++ b/api/tests/integration_tests/workflow/nodes/test_http.py @@ -14,7 +14,7 @@ from core.workflow.system_variables import build_system_variables from graphon.enums import WorkflowNodeExecutionStatus from graphon.file.file_manager import file_manager from graphon.graph import Graph -from graphon.nodes.http_request import HttpRequestNode, HttpRequestNodeConfig +from graphon.nodes.http_request import HttpRequestNode, HttpRequestNodeConfig, HttpRequestNodeData from graphon.runtime import GraphRuntimeState, VariablePool from tests.workflow_test_utils import build_test_graph_init_params @@ -75,8 +75,8 @@ def init_http_node(config: dict): graph = Graph.init(graph_config=graph_config, node_factory=node_factory, root_node_id="start") node = HttpRequestNode( - id=str(uuid.uuid4()), - config=config, + node_id=str(uuid.uuid4()), + config=HttpRequestNodeData.model_validate(config["data"]), graph_init_params=init_params, graph_runtime_state=graph_runtime_state, http_request_config=HTTP_REQUEST_CONFIG, @@ -723,8 +723,8 @@ def test_nested_object_variable_selector(setup_http_mock): graph = Graph.init(graph_config=graph_config, node_factory=node_factory, root_node_id="start") node = HttpRequestNode( - id=str(uuid.uuid4()), - config=graph_config["nodes"][1], + node_id=str(uuid.uuid4()), + config=HttpRequestNodeData.model_validate(graph_config["nodes"][1]["data"]), graph_init_params=init_params, graph_runtime_state=graph_runtime_state, http_request_config=HTTP_REQUEST_CONFIG, diff --git a/api/tests/integration_tests/workflow/nodes/test_llm.py b/api/tests/integration_tests/workflow/nodes/test_llm.py index fa5d63cfbf..3eead70163 100644 --- a/api/tests/integration_tests/workflow/nodes/test_llm.py +++ b/api/tests/integration_tests/workflow/nodes/test_llm.py @@ -11,6 +11,7 @@ from core.workflow.system_variables import build_system_variables from extensions.ext_database import db from graphon.enums import WorkflowNodeExecutionStatus from graphon.node_events import StreamCompletedEvent +from graphon.nodes.llm.entities import LLMNodeData from graphon.nodes.llm.file_saver import LLMFileSaver from graphon.nodes.llm.node import LLMNode from graphon.nodes.llm.protocols import CredentialsProvider, ModelFactory @@ -75,8 +76,8 @@ def init_llm_node(config: dict) -> LLMNode: llm_file_saver = MagicMock(spec=LLMFileSaver) node = LLMNode( - id=str(uuid.uuid4()), - config=config, + node_id=str(uuid.uuid4()), + config=LLMNodeData.model_validate(config["data"]), graph_init_params=init_params, graph_runtime_state=graph_runtime_state, credentials_provider=MagicMock(spec=CredentialsProvider), diff --git a/api/tests/integration_tests/workflow/nodes/test_parameter_extractor.py b/api/tests/integration_tests/workflow/nodes/test_parameter_extractor.py index 52886855b8..f2eabb86c3 100644 --- a/api/tests/integration_tests/workflow/nodes/test_parameter_extractor.py +++ b/api/tests/integration_tests/workflow/nodes/test_parameter_extractor.py @@ -11,6 +11,7 @@ from extensions.ext_database import db from graphon.enums import WorkflowNodeExecutionStatus from graphon.model_runtime.entities import AssistantPromptMessage, UserPromptMessage from graphon.nodes.llm.protocols import CredentialsProvider, ModelFactory +from graphon.nodes.parameter_extractor.entities import ParameterExtractorNodeData from graphon.nodes.parameter_extractor.parameter_extractor_node import ParameterExtractorNode from graphon.runtime import GraphRuntimeState, VariablePool from tests.integration_tests.workflow.nodes.__mock.model import get_mocked_fetch_model_instance @@ -69,8 +70,8 @@ def init_parameter_extractor_node(config: dict, memory=None): graph_runtime_state = GraphRuntimeState(variable_pool=variable_pool, start_at=time.perf_counter()) node = ParameterExtractorNode( - id=str(uuid.uuid4()), - config=config, + node_id=str(uuid.uuid4()), + config=ParameterExtractorNodeData.model_validate(config["data"]), graph_init_params=init_params, graph_runtime_state=graph_runtime_state, credentials_provider=MagicMock(spec=CredentialsProvider), diff --git a/api/tests/integration_tests/workflow/nodes/test_template_transform.py b/api/tests/integration_tests/workflow/nodes/test_template_transform.py index 9e3e1a47e3..e2e0723fb8 100644 --- a/api/tests/integration_tests/workflow/nodes/test_template_transform.py +++ b/api/tests/integration_tests/workflow/nodes/test_template_transform.py @@ -6,6 +6,7 @@ from core.workflow.node_factory import DifyNodeFactory from core.workflow.system_variables import build_system_variables from graphon.enums import WorkflowNodeExecutionStatus from graphon.graph import Graph +from graphon.nodes.template_transform.entities import TemplateTransformNodeData from graphon.nodes.template_transform.template_transform_node import TemplateTransformNode from graphon.runtime import GraphRuntimeState, VariablePool from graphon.template_rendering import TemplateRenderError @@ -86,8 +87,8 @@ def test_execute_template_transform(): assert graph is not None node = TemplateTransformNode( - id=str(uuid.uuid4()), - config=config, + node_id=str(uuid.uuid4()), + config=TemplateTransformNodeData.model_validate(config["data"]), graph_init_params=init_params, graph_runtime_state=graph_runtime_state, jinja2_template_renderer=_SimpleJinja2Renderer(), diff --git a/api/tests/integration_tests/workflow/nodes/test_tool.py b/api/tests/integration_tests/workflow/nodes/test_tool.py index f9ec51ee10..a8e9422c1e 100644 --- a/api/tests/integration_tests/workflow/nodes/test_tool.py +++ b/api/tests/integration_tests/workflow/nodes/test_tool.py @@ -11,6 +11,7 @@ from graphon.enums import WorkflowNodeExecutionStatus from graphon.graph import Graph from graphon.node_events import StreamCompletedEvent from graphon.nodes.protocols import ToolFileManagerProtocol +from graphon.nodes.tool.entities import ToolNodeData from graphon.nodes.tool.tool_node import ToolNode from graphon.runtime import GraphRuntimeState, VariablePool from tests.workflow_test_utils import build_test_graph_init_params @@ -60,8 +61,8 @@ def init_tool_node(config: dict): tool_file_manager_factory = MagicMock(spec=ToolFileManagerProtocol) node = ToolNode( - id=str(uuid.uuid4()), - config=config, + node_id=str(uuid.uuid4()), + config=ToolNodeData.model_validate(config["data"]), graph_init_params=init_params, graph_runtime_state=graph_runtime_state, tool_file_manager_factory=tool_file_manager_factory, diff --git a/api/tests/test_containers_integration_tests/core/repositories/test_human_input_form_repository_impl.py b/api/tests/test_containers_integration_tests/core/repositories/test_human_input_form_repository_impl.py index 14d5740072..6524d6ce61 100644 --- a/api/tests/test_containers_integration_tests/core/repositories/test_human_input_form_repository_impl.py +++ b/api/tests/test_containers_integration_tests/core/repositories/test_human_input_form_repository_impl.py @@ -8,7 +8,7 @@ from sqlalchemy import Engine, select from sqlalchemy.orm import Session from core.repositories.human_input_repository import FormCreateParams, HumanInputFormRepositoryImpl -from core.workflow.human_input_compat import ( +from core.workflow.human_input_adapter import ( DeliveryChannelConfig, EmailDeliveryConfig, EmailDeliveryMethod, diff --git a/api/tests/test_containers_integration_tests/core/workflow/test_human_input_resume_node_execution.py b/api/tests/test_containers_integration_tests/core/workflow/test_human_input_resume_node_execution.py index da4f8847d6..5aed230cd4 100644 --- a/api/tests/test_containers_integration_tests/core/workflow/test_human_input_resume_node_execution.py +++ b/api/tests/test_containers_integration_tests/core/workflow/test_human_input_resume_node_execution.py @@ -101,8 +101,8 @@ def _build_graph( start_data = StartNodeData(title="start", variables=[]) start_node = StartNode( - id="start", - config={"id": "start", "data": start_data.model_dump()}, + node_id="start", + config=start_data, graph_init_params=params, graph_runtime_state=runtime_state, ) @@ -116,8 +116,8 @@ def _build_graph( ], ) human_node = HumanInputNode( - id="human", - config={"id": "human", "data": human_data.model_dump()}, + node_id="human", + config=human_data, graph_init_params=params, graph_runtime_state=runtime_state, form_repository=form_repository, @@ -130,8 +130,8 @@ def _build_graph( desc=None, ) end_node = EndNode( - id="end", - config={"id": "end", "data": end_data.model_dump()}, + node_id="end", + config=end_data, graph_init_params=params, graph_runtime_state=runtime_state, ) diff --git a/api/tests/test_containers_integration_tests/factories/test_storage_key_loader.py b/api/tests/test_containers_integration_tests/factories/test_storage_key_loader.py index 2e207ddc67..35e41035df 100644 --- a/api/tests/test_containers_integration_tests/factories/test_storage_key_loader.py +++ b/api/tests/test_containers_integration_tests/factories/test_storage_key_loader.py @@ -123,9 +123,9 @@ class TestStorageKeyLoader(unittest.TestCase): file_related_id = related_id return File( - id=str(uuid4()), # Generate new UUID for File.id + file_id=str(uuid4()), # Generate new UUID for File.id tenant_id=tenant_id, - type=FileType.DOCUMENT, + file_type=FileType.DOCUMENT, transfer_method=transfer_method, related_id=file_related_id, remote_url=remote_url, diff --git a/api/tests/test_containers_integration_tests/repositories/test_sqlalchemy_execution_extra_content_repository.py b/api/tests/test_containers_integration_tests/repositories/test_sqlalchemy_execution_extra_content_repository.py index aaf9a85d60..54b7afc018 100644 --- a/api/tests/test_containers_integration_tests/repositories/test_sqlalchemy_execution_extra_content_repository.py +++ b/api/tests/test_containers_integration_tests/repositories/test_sqlalchemy_execution_extra_content_repository.py @@ -271,7 +271,7 @@ def _create_recipient( def _create_delivery(session: Session, *, form_id: str) -> HumanInputDelivery: - from core.workflow.human_input_compat import DeliveryMethodType + from core.workflow.human_input_adapter import DeliveryMethodType from models.human_input import ConsoleDeliveryPayload delivery = HumanInputDelivery( diff --git a/api/tests/test_containers_integration_tests/services/test_human_input_delivery_test.py b/api/tests/test_containers_integration_tests/services/test_human_input_delivery_test.py index 18c5320d0a..80f9083e81 100644 --- a/api/tests/test_containers_integration_tests/services/test_human_input_delivery_test.py +++ b/api/tests/test_containers_integration_tests/services/test_human_input_delivery_test.py @@ -4,7 +4,7 @@ from unittest.mock import MagicMock import pytest -from core.workflow.human_input_compat import ( +from core.workflow.human_input_adapter import ( EmailDeliveryConfig, EmailDeliveryMethod, EmailRecipients, diff --git a/api/tests/test_containers_integration_tests/services/test_human_input_delivery_test_service.py b/api/tests/test_containers_integration_tests/services/test_human_input_delivery_test_service.py index 21a54e909e..ed75363f3b 100644 --- a/api/tests/test_containers_integration_tests/services/test_human_input_delivery_test_service.py +++ b/api/tests/test_containers_integration_tests/services/test_human_input_delivery_test_service.py @@ -8,7 +8,7 @@ import pytest from sqlalchemy.engine import Engine from configs import dify_config -from core.workflow.human_input_compat import ( +from core.workflow.human_input_adapter import ( EmailDeliveryConfig, EmailDeliveryMethod, EmailRecipients, diff --git a/api/tests/test_containers_integration_tests/tasks/test_mail_human_input_delivery_task.py b/api/tests/test_containers_integration_tests/tasks/test_mail_human_input_delivery_task.py index 328bdbf055..95a867dbb5 100644 --- a/api/tests/test_containers_integration_tests/tasks/test_mail_human_input_delivery_task.py +++ b/api/tests/test_containers_integration_tests/tasks/test_mail_human_input_delivery_task.py @@ -10,7 +10,7 @@ from core.app.app_config.entities import WorkflowUIBasedAppConfig from core.app.entities.app_invoke_entities import InvokeFrom, WorkflowAppGenerateEntity from core.app.layers.pause_state_persist_layer import WorkflowResumptionContext from core.repositories.human_input_repository import FormCreateParams, HumanInputFormRepositoryImpl -from core.workflow.human_input_compat import ( +from core.workflow.human_input_adapter import ( EmailDeliveryConfig, EmailDeliveryMethod, EmailRecipients, diff --git a/api/tests/unit_tests/controllers/console/app/test_workflow.py b/api/tests/unit_tests/controllers/console/app/test_workflow.py index 6ff3b19362..e91c0a0597 100644 --- a/api/tests/unit_tests/controllers/console/app/test_workflow.py +++ b/api/tests/unit_tests/controllers/console/app/test_workflow.py @@ -31,7 +31,7 @@ def test_parse_file_with_config(monkeypatch: pytest.MonkeyPatch) -> None: file_list = [ File( tenant_id="t1", - type=FileType.IMAGE, + file_type=FileType.IMAGE, transfer_method=FileTransferMethod.REMOTE_URL, remote_url="http://u", ) diff --git a/api/tests/unit_tests/controllers/console/app/workflow_draft_variables_test.py b/api/tests/unit_tests/controllers/console/app/workflow_draft_variables_test.py index b19a1740eb..22b80b748e 100644 --- a/api/tests/unit_tests/controllers/console/app/workflow_draft_variables_test.py +++ b/api/tests/unit_tests/controllers/console/app/workflow_draft_variables_test.py @@ -314,8 +314,8 @@ def test_workflow_file_variable_with_signed_url(): # Create a File object with LOCAL_FILE transfer method (which generates signed URLs) test_file = File( - id="test_file_id", - type=FileType.IMAGE, + file_id="test_file_id", + file_type=FileType.IMAGE, transfer_method=FileTransferMethod.LOCAL_FILE, related_id="test_upload_file_id", filename="test.jpg", @@ -370,8 +370,8 @@ def test_workflow_file_variable_remote_url(): # Create a File object with REMOTE_URL transfer method test_file = File( - id="test_file_id", - type=FileType.IMAGE, + file_id="test_file_id", + file_type=FileType.IMAGE, transfer_method=FileTransferMethod.REMOTE_URL, remote_url="https://example.com/test.jpg", filename="test.jpg", diff --git a/api/tests/unit_tests/controllers/service_api/app/test_conversation.py b/api/tests/unit_tests/controllers/service_api/app/test_conversation.py index 14c35a9ed5..4fb8ecf784 100644 --- a/api/tests/unit_tests/controllers/service_api/app/test_conversation.py +++ b/api/tests/unit_tests/controllers/service_api/app/test_conversation.py @@ -37,6 +37,8 @@ from controllers.service_api.app.conversation import ( ConversationVariableUpdatePayload, ) from controllers.service_api.app.error import NotChatAppError +from fields._value_type_serializer import serialize_value_type +from graphon.variables import StringSegment from graphon.variables.types import SegmentType from models.model import App, AppMode, EndUser from services.conversation_service import ConversationService @@ -284,6 +286,32 @@ class TestConversationVariableResponseModels: assert response.created_at == int(created_at.timestamp()) assert response.updated_at == int(created_at.timestamp()) + def test_variable_response_normalizes_string_value_type_alias(self): + response = ConversationVariableResponse.model_validate( + { + "id": "550e8400-e29b-41d4-a716-446655440000", + "name": "foo", + "value_type": SegmentType.INTEGER.value, + } + ) + + assert response.value_type == "number" + + def test_variable_response_normalizes_callable_exposed_type(self): + response = ConversationVariableResponse.model_validate( + { + "id": "550e8400-e29b-41d4-a716-446655440000", + "name": "foo", + "value_type": SimpleNamespace(exposed_type=lambda: SegmentType.STRING.exposed_type()), + } + ) + + assert response.value_type == "string" + + def test_serialize_value_type_supports_segments_and_mappings(self): + assert serialize_value_type(StringSegment(value="hello")) == "string" + assert serialize_value_type({"value_type": SegmentType.INTEGER}) == "number" + def test_variable_pagination_response(self): response = ConversationVariableInfiniteScrollPaginationResponse.model_validate( { diff --git a/api/tests/unit_tests/core/app/apps/common/test_workflow_response_converter.py b/api/tests/unit_tests/core/app/apps/common/test_workflow_response_converter.py index 3ab63aed25..dd6cd0e919 100644 --- a/api/tests/unit_tests/core/app/apps/common/test_workflow_response_converter.py +++ b/api/tests/unit_tests/core/app/apps/common/test_workflow_response_converter.py @@ -11,8 +11,8 @@ class TestWorkflowResponseConverterFetchFilesFromVariableValue: def create_test_file(self, file_id: str = "test_file_1") -> File: """Create a test File object""" return File( - id=file_id, - type=FileType.DOCUMENT, + file_id=file_id, + file_type=FileType.DOCUMENT, transfer_method=FileTransferMethod.LOCAL_FILE, related_id="related_123", filename=f"{file_id}.txt", diff --git a/api/tests/unit_tests/core/app/apps/test_pause_resume.py b/api/tests/unit_tests/core/app/apps/test_pause_resume.py index a04a7b7576..6104b8d6ca 100644 --- a/api/tests/unit_tests/core/app/apps/test_pause_resume.py +++ b/api/tests/unit_tests/core/app/apps/test_pause_resume.py @@ -7,11 +7,11 @@ import graphon.nodes.human_input.entities # noqa: F401 from core.app.apps.advanced_chat import app_generator as adv_app_gen_module from core.app.apps.workflow import app_generator as wf_app_gen_module from core.app.entities.app_invoke_entities import InvokeFrom +from core.workflow import node_factory as node_factory_module from core.workflow.node_factory import DifyNodeFactory from core.workflow.system_variables import build_system_variables from graphon.entities import WorkflowStartReason from graphon.entities.base_node_data import BaseNodeData, RetryConfig -from graphon.entities.graph_config import NodeConfigDict, NodeConfigDictAdapter from graphon.entities.pause_reason import SchedulingPause from graphon.enums import BuiltinNodeTypes, NodeType, WorkflowNodeExecutionStatus from graphon.graph import Graph @@ -55,8 +55,21 @@ class _StubToolNode(Node[_StubToolNodeData]): def version(cls) -> str: return "1" - def init_node_data(self, data): - self._node_data = _StubToolNodeData.model_validate(data) + def __init__( + self, + node_id: str, + config: _StubToolNodeData, + *, + graph_init_params, + graph_runtime_state, + **_kwargs: Any, + ) -> None: + super().__init__( + node_id=node_id, + config=config, + graph_init_params=graph_init_params, + graph_runtime_state=graph_runtime_state, + ) def _get_error_strategy(self): return self._node_data.error_strategy @@ -89,21 +102,14 @@ class _StubToolNode(Node[_StubToolNodeData]): def _patch_tool_node(mocker): - original_create_node = DifyNodeFactory.create_node + original_resolve_node_class = node_factory_module.resolve_workflow_node_class - def _patched_create_node(self, node_config: dict[str, object] | NodeConfigDict) -> Node: - typed_node_config = NodeConfigDictAdapter.validate_python(node_config) - node_data = typed_node_config["data"] - if node_data.type == BuiltinNodeTypes.TOOL: - return _StubToolNode( - id=str(typed_node_config["id"]), - config=typed_node_config, - graph_init_params=self.graph_init_params, - graph_runtime_state=self.graph_runtime_state, - ) - return original_create_node(self, typed_node_config) + def _patched_resolve_node_class(*, node_type: NodeType, node_version: str) -> type[Node]: + if node_type == BuiltinNodeTypes.TOOL: + return _StubToolNode + return original_resolve_node_class(node_type=node_type, node_version=node_version) - mocker.patch.object(DifyNodeFactory, "create_node", _patched_create_node) + mocker.patch.object(node_factory_module, "resolve_workflow_node_class", side_effect=_patched_resolve_node_class) def _node_data(node_type: NodeType, data: BaseNodeData) -> dict[str, object]: diff --git a/api/tests/unit_tests/core/app/workflow/test_file_runtime.py b/api/tests/unit_tests/core/app/workflow/test_file_runtime.py index cddd03f4b0..701863b927 100644 --- a/api/tests/unit_tests/core/app/workflow/test_file_runtime.py +++ b/api/tests/unit_tests/core/app/workflow/test_file_runtime.py @@ -26,8 +26,8 @@ def _build_file( extension: str | None = None, ) -> File: return File( - id="file-id", - type=FileType.IMAGE, + file_id="file-id", + file_type=FileType.IMAGE, transfer_method=transfer_method, reference=reference, remote_url=remote_url, @@ -351,7 +351,7 @@ def test_runtime_helper_wrappers_delegate_to_config_and_io(monkeypatch: pytest.M assert runtime.multimodal_send_format == "url" - with patch.object(file_runtime.ssrf_proxy, "get", return_value="response") as mock_get: + with patch.object(file_runtime.graphon_ssrf_proxy, "get", return_value="response") as mock_get: assert runtime.http_get("http://example", follow_redirects=False) == "response" mock_get.assert_called_once_with("http://example", follow_redirects=False) diff --git a/api/tests/unit_tests/core/app/workflow/test_node_factory.py b/api/tests/unit_tests/core/app/workflow/test_node_factory.py index c4bfb23272..30a068f4c5 100644 --- a/api/tests/unit_tests/core/app/workflow/test_node_factory.py +++ b/api/tests/unit_tests/core/app/workflow/test_node_factory.py @@ -8,8 +8,8 @@ from graphon.enums import BuiltinNodeTypes class DummyNode: - def __init__(self, *, id, config, graph_init_params, graph_runtime_state, **kwargs): - self.id = id + def __init__(self, *, node_id, config, graph_init_params, graph_runtime_state, **kwargs): + self.id = node_id self.config = config self.graph_init_params = graph_init_params self.graph_runtime_state = graph_runtime_state diff --git a/api/tests/unit_tests/core/datasource/test_datasource_manager.py b/api/tests/unit_tests/core/datasource/test_datasource_manager.py index 81315d2508..deeac49bbc 100644 --- a/api/tests/unit_tests/core/datasource/test_datasource_manager.py +++ b/api/tests/unit_tests/core/datasource/test_datasource_manager.py @@ -430,7 +430,7 @@ def test_stream_node_events_builds_file_and_variables_from_messages(mocker): mocker.patch("core.datasource.datasource_manager.session_factory.create_session", return_value=_Session()) mocker.patch("core.datasource.datasource_manager.get_file_type_by_mime_type", return_value=FileType.IMAGE) built = File( - type=FileType.IMAGE, + file_type=FileType.IMAGE, transfer_method=FileTransferMethod.TOOL_FILE, related_id="tool_file_1", extension=".png", @@ -530,7 +530,7 @@ def test_stream_node_events_online_drive_sets_variable_pool_file_and_outputs(moc mocker.patch.object(DatasourceManager, "stream_online_results", return_value=_gen_messages_text_only("ignored")) file_in = File( - type=FileType.DOCUMENT, + file_type=FileType.DOCUMENT, transfer_method=FileTransferMethod.TOOL_FILE, related_id="tf", extension=".pdf", diff --git a/api/tests/unit_tests/core/entities/test_entities_model_entities.py b/api/tests/unit_tests/core/entities/test_entities_model_entities.py index a0b2820157..aeca2e3afd 100644 --- a/api/tests/unit_tests/core/entities/test_entities_model_entities.py +++ b/api/tests/unit_tests/core/entities/test_entities_model_entities.py @@ -46,7 +46,7 @@ def test_simple_model_provider_entity_maps_from_provider_entity() -> None: # Assert assert simple_provider.provider == "openai" - assert simple_provider.label.en_US == "OpenAI" + assert simple_provider.label.en_us == "OpenAI" assert simple_provider.supported_model_types == [ModelType.LLM] diff --git a/api/tests/unit_tests/core/file/test_models.py b/api/tests/unit_tests/core/file/test_models.py index bb6e40e224..8cb0938575 100644 --- a/api/tests/unit_tests/core/file/test_models.py +++ b/api/tests/unit_tests/core/file/test_models.py @@ -3,9 +3,9 @@ from graphon.file import File, FileTransferMethod, FileType def test_file(): file = File( - id="test-file", + file_id="test-file", tenant_id="test-tenant-id", - type=FileType.IMAGE, + file_type=FileType.IMAGE, transfer_method=FileTransferMethod.TOOL_FILE, related_id="test-related-id", filename="image.png", @@ -25,27 +25,21 @@ def test_file(): assert file.size == 67 -def test_file_model_validate_accepts_legacy_tenant_id(): - data = { - "id": "test-file", - "tenant_id": "test-tenant-id", - "type": "image", - "transfer_method": "tool_file", - "related_id": "test-related-id", - "filename": "image.png", - "extension": ".png", - "mime_type": "image/png", - "size": 67, - "storage_key": "test-storage-key", - "url": "https://example.com/image.png", - # Extra legacy fields - "tool_file_id": "tool-file-123", - "upload_file_id": "upload-file-456", - "datasource_file_id": "datasource-file-789", - } +def test_file_constructor_accepts_legacy_tenant_id(): + file = File( + file_id="test-file", + tenant_id="test-tenant-id", + file_type=FileType.IMAGE, + transfer_method=FileTransferMethod.TOOL_FILE, + tool_file_id="tool-file-123", + filename="image.png", + extension=".png", + mime_type="image/png", + size=67, + storage_key="test-storage-key", + url="https://example.com/image.png", + ) - file = File.model_validate(data) - - assert file.related_id == "test-related-id" + assert file.related_id == "tool-file-123" assert file.storage_key == "test-storage-key" assert "tenant_id" not in file.model_dump() diff --git a/api/tests/unit_tests/core/helper/test_ssrf_proxy.py b/api/tests/unit_tests/core/helper/test_ssrf_proxy.py index 3b5c5e6597..d9fed9ae2a 100644 --- a/api/tests/unit_tests/core/helper/test_ssrf_proxy.py +++ b/api/tests/unit_tests/core/helper/test_ssrf_proxy.py @@ -1,11 +1,17 @@ from unittest.mock import MagicMock, patch +import httpx import pytest from core.helper.ssrf_proxy import ( SSRF_DEFAULT_MAX_RETRIES, + SSRFProxy, _get_user_provided_host_header, + _to_graphon_http_response, + graphon_ssrf_proxy, make_request, + max_retries_exceeded_error, + request_error, ) @@ -174,3 +180,56 @@ class TestFollowRedirectsParameter: call_kwargs = mock_client.request.call_args.kwargs assert call_kwargs.get("follow_redirects") is True + + +def test_to_graphon_http_response_preserves_httpx_response_fields() -> None: + response = httpx.Response( + 201, + headers={"X-Test": "1"}, + content=b"payload", + request=httpx.Request("GET", "https://example.com/resource"), + ) + + wrapped = _to_graphon_http_response(response) + + assert wrapped.status_code == 201 + assert wrapped.headers == {"x-test": "1", "content-length": "7"} + assert wrapped.content == b"payload" + assert wrapped.url == "https://example.com/resource" + assert wrapped.reason_phrase == "Created" + assert wrapped.text == "payload" + + +def test_ssrf_proxy_exposes_expected_error_types() -> None: + proxy = SSRFProxy() + + assert proxy.max_retries_exceeded_error is max_retries_exceeded_error + assert proxy.request_error is request_error + assert graphon_ssrf_proxy.max_retries_exceeded_error is max_retries_exceeded_error + assert graphon_ssrf_proxy.request_error is request_error + + +@pytest.mark.parametrize("method_name", ["get", "head", "post", "put", "delete", "patch"]) +def test_graphon_ssrf_proxy_wraps_module_requests(method_name: str) -> None: + response = httpx.Response( + 200, + headers={"X-Test": "1"}, + content=b"ok", + request=httpx.Request("GET", "https://example.com/resource"), + ) + + with patch(f"core.helper.ssrf_proxy.{method_name}", return_value=response) as mock_method: + wrapped = getattr(graphon_ssrf_proxy, method_name)( + "https://example.com/resource", + max_retries=3, + headers={"X-Test": "1"}, + ) + + mock_method.assert_called_once_with( + url="https://example.com/resource", + max_retries=3, + headers={"X-Test": "1"}, + ) + assert wrapped.status_code == 200 + assert wrapped.url == "https://example.com/resource" + assert wrapped.content == b"ok" diff --git a/api/tests/unit_tests/core/model_runtime/test_model_provider_factory.py b/api/tests/unit_tests/core/model_runtime/test_model_provider_factory.py index 249ecb5006..c4fd970562 100644 --- a/api/tests/unit_tests/core/model_runtime/test_model_provider_factory.py +++ b/api/tests/unit_tests/core/model_runtime/test_model_provider_factory.py @@ -13,12 +13,12 @@ from graphon.model_runtime.entities.provider_entities import ( ProviderCredentialSchema, ProviderEntity, ) -from graphon.model_runtime.model_providers.__base.large_language_model import LargeLanguageModel -from graphon.model_runtime.model_providers.__base.moderation_model import ModerationModel -from graphon.model_runtime.model_providers.__base.rerank_model import RerankModel -from graphon.model_runtime.model_providers.__base.speech2text_model import Speech2TextModel -from graphon.model_runtime.model_providers.__base.text_embedding_model import TextEmbeddingModel -from graphon.model_runtime.model_providers.__base.tts_model import TTSModel +from graphon.model_runtime.model_providers.base.large_language_model import LargeLanguageModel +from graphon.model_runtime.model_providers.base.moderation_model import ModerationModel +from graphon.model_runtime.model_providers.base.rerank_model import RerankModel +from graphon.model_runtime.model_providers.base.speech2text_model import Speech2TextModel +from graphon.model_runtime.model_providers.base.text_embedding_model import TextEmbeddingModel +from graphon.model_runtime.model_providers.base.tts_model import TTSModel from graphon.model_runtime.model_providers.model_provider_factory import ModelProviderFactory diff --git a/api/tests/unit_tests/core/plugin/test_model_runtime_adapter.py b/api/tests/unit_tests/core/plugin/test_model_runtime_adapter.py index 68aa130518..88bf555594 100644 --- a/api/tests/unit_tests/core/plugin/test_model_runtime_adapter.py +++ b/api/tests/unit_tests/core/plugin/test_model_runtime_adapter.py @@ -56,7 +56,7 @@ class TestPluginModelRuntime: assert len(providers) == 1 assert providers[0].provider == "langgenius/openai/openai" assert providers[0].provider_name == "openai" - assert providers[0].label.en_US == "OpenAI" + assert providers[0].label.en_us == "OpenAI" client.fetch_model_providers.assert_called_once_with("tenant") def test_fetch_model_providers_only_exposes_short_name_for_canonical_provider(self) -> None: diff --git a/api/tests/unit_tests/core/plugin/utils/test_chunk_merger.py b/api/tests/unit_tests/core/plugin/utils/test_chunk_merger.py index d49b6e4b71..00a4207786 100644 --- a/api/tests/unit_tests/core/plugin/utils/test_chunk_merger.py +++ b/api/tests/unit_tests/core/plugin/utils/test_chunk_merger.py @@ -466,7 +466,7 @@ class TestConverter: def test_convert_parameters_to_plugin_format_with_single_file_and_selector(self): file_param = File( tenant_id="tenant-1", - type=FileType.IMAGE, + file_type=FileType.IMAGE, transfer_method=FileTransferMethod.REMOTE_URL, remote_url="https://example.com/file.png", storage_key="", @@ -499,14 +499,14 @@ class TestConverter: def test_convert_parameters_to_plugin_format_with_lists_and_passthrough_values(self): file_one = File( tenant_id="tenant-1", - type=FileType.DOCUMENT, + file_type=FileType.DOCUMENT, transfer_method=FileTransferMethod.REMOTE_URL, remote_url="https://example.com/a.txt", storage_key="", ) file_two = File( tenant_id="tenant-1", - type=FileType.DOCUMENT, + file_type=FileType.DOCUMENT, transfer_method=FileTransferMethod.REMOTE_URL, remote_url="https://example.com/b.txt", storage_key="", diff --git a/api/tests/unit_tests/core/prompt/test_advanced_prompt_transform.py b/api/tests/unit_tests/core/prompt/test_advanced_prompt_transform.py index 395d392127..e536c0831f 100644 --- a/api/tests/unit_tests/core/prompt/test_advanced_prompt_transform.py +++ b/api/tests/unit_tests/core/prompt/test_advanced_prompt_transform.py @@ -134,9 +134,9 @@ def test__get_chat_model_prompt_messages_with_files_no_memory(get_chat_model_arg files = [ File( - id="file1", + file_id="file1", tenant_id="tenant1", - type=FileType.IMAGE, + file_type=FileType.IMAGE, transfer_method=FileTransferMethod.REMOTE_URL, remote_url="https://example.com/image1.jpg", storage_key="", @@ -245,9 +245,9 @@ def test_completion_prompt_jinja2_with_files(): completion_template = CompletionModelPromptTemplate(text="Hi {{name}}", edition_type="jinja2") file = File( - id="file1", + file_id="file1", tenant_id="tenant1", - type=FileType.IMAGE, + file_type=FileType.IMAGE, transfer_method=FileTransferMethod.REMOTE_URL, remote_url="https://example.com/image.jpg", storage_key="", @@ -379,9 +379,9 @@ def test_chat_prompt_memory_with_files_and_query(): memory = MagicMock(spec=TokenBufferMemory) prompt_template = [ChatModelMessage(text="sys", role=PromptMessageRole.SYSTEM)] file = File( - id="file1", + file_id="file1", tenant_id="tenant1", - type=FileType.IMAGE, + file_type=FileType.IMAGE, transfer_method=FileTransferMethod.REMOTE_URL, remote_url="https://example.com/image.jpg", storage_key="", @@ -413,9 +413,9 @@ def test_chat_prompt_files_without_query_updates_last_user_or_appends_new(): transform = AdvancedPromptTransform() model_config_mock = MagicMock(spec=ModelConfigEntity) file = File( - id="file1", + file_id="file1", tenant_id="tenant1", - type=FileType.IMAGE, + file_type=FileType.IMAGE, transfer_method=FileTransferMethod.REMOTE_URL, remote_url="https://example.com/image.jpg", storage_key="", @@ -463,9 +463,9 @@ def test_chat_prompt_files_with_query_branch(): transform = AdvancedPromptTransform() model_config_mock = MagicMock(spec=ModelConfigEntity) file = File( - id="file1", + file_id="file1", tenant_id="tenant1", - type=FileType.IMAGE, + file_type=FileType.IMAGE, transfer_method=FileTransferMethod.REMOTE_URL, remote_url="https://example.com/image.jpg", storage_key="", diff --git a/api/tests/unit_tests/core/prompt/test_agent_history_prompt_transform.py b/api/tests/unit_tests/core/prompt/test_agent_history_prompt_transform.py index 803afa54d7..28966242d8 100644 --- a/api/tests/unit_tests/core/prompt/test_agent_history_prompt_transform.py +++ b/api/tests/unit_tests/core/prompt/test_agent_history_prompt_transform.py @@ -12,7 +12,7 @@ from graphon.model_runtime.entities.message_entities import ( ToolPromptMessage, UserPromptMessage, ) -from graphon.model_runtime.model_providers.__base.large_language_model import LargeLanguageModel +from graphon.model_runtime.model_providers.base.large_language_model import LargeLanguageModel from models.model import Conversation diff --git a/api/tests/unit_tests/core/prompt/test_prompt_transform.py b/api/tests/unit_tests/core/prompt/test_prompt_transform.py index 9f9ea33695..5308c8e7b3 100644 --- a/api/tests/unit_tests/core/prompt/test_prompt_transform.py +++ b/api/tests/unit_tests/core/prompt/test_prompt_transform.py @@ -11,7 +11,7 @@ from graphon.model_runtime.entities.model_entities import ModelPropertyKey # from graphon.model_runtime.entities.message_entities import UserPromptMessage # from graphon.model_runtime.entities.model_entities import AIModelEntity, ModelPropertyKey, ParameterRule # from graphon.model_runtime.entities.provider_entities import ProviderEntity -# from graphon.model_runtime.model_providers.__base.large_language_model import LargeLanguageModel +# from graphon.model_runtime.model_providers.base.large_language_model import LargeLanguageModel # from core.prompt.prompt_transform import PromptTransform diff --git a/api/tests/unit_tests/core/rag/extractor/test_word_extractor.py b/api/tests/unit_tests/core/rag/extractor/test_word_extractor.py index 64eb89590a..0220fb6d4a 100644 --- a/api/tests/unit_tests/core/rag/extractor/test_word_extractor.py +++ b/api/tests/unit_tests/core/rag/extractor/test_word_extractor.py @@ -1,12 +1,14 @@ """Primarily used for testing merged cell scenarios""" +import gc import io import os import tempfile +import warnings from collections import UserDict from pathlib import Path from types import SimpleNamespace -from unittest.mock import MagicMock +from unittest.mock import AsyncMock, MagicMock import pytest from docx import Document @@ -354,15 +356,46 @@ def test_init_expands_home_path_and_invalid_local_path(monkeypatch, tmp_path): WordExtractor("not-a-file", "tenant", "user") -def test_del_closes_temp_file(): +def test_close_closes_temp_file(): extractor = object.__new__(WordExtractor) + extractor._closed = False extractor.temp_file = MagicMock() - WordExtractor.__del__(extractor) + extractor.close() extractor.temp_file.close.assert_called_once() +def test_close_is_idempotent(): + extractor = object.__new__(WordExtractor) + extractor._closed = False + extractor.temp_file = MagicMock() + + extractor.close() + extractor.close() + + extractor.temp_file.close.assert_called_once() + + +def test_close_handles_async_close_mock(): + extractor = object.__new__(WordExtractor) + extractor._closed = False + extractor.temp_file = MagicMock() + extractor.temp_file.close = AsyncMock() + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + extractor.close() + gc.collect() + + extractor.temp_file.close.assert_called_once() + assert not [ + warning + for warning in caught + if issubclass(warning.category, RuntimeWarning) and "AsyncMockMixin._execute_mock_call" in str(warning.message) + ] + + def test_extract_images_handles_invalid_external_cases(monkeypatch): class FakeTargetRef: def __contains__(self, item): diff --git a/api/tests/unit_tests/core/repositories/test_human_input_form_repository_impl.py b/api/tests/unit_tests/core/repositories/test_human_input_form_repository_impl.py index 8be1ac318c..18ae9fafc8 100644 --- a/api/tests/unit_tests/core/repositories/test_human_input_form_repository_impl.py +++ b/api/tests/unit_tests/core/repositories/test_human_input_form_repository_impl.py @@ -14,7 +14,7 @@ from core.repositories.human_input_repository import ( HumanInputFormSubmissionRepository, _WorkspaceMemberInfo, ) -from core.workflow.human_input_compat import ( +from core.workflow.human_input_adapter import ( EmailDeliveryConfig, EmailDeliveryMethod, EmailRecipients, diff --git a/api/tests/unit_tests/core/repositories/test_human_input_repository.py b/api/tests/unit_tests/core/repositories/test_human_input_repository.py index 1297a95df1..4248782d93 100644 --- a/api/tests/unit_tests/core/repositories/test_human_input_repository.py +++ b/api/tests/unit_tests/core/repositories/test_human_input_repository.py @@ -21,7 +21,7 @@ from core.repositories.human_input_repository import ( _InvalidTimeoutStatusError, _WorkspaceMemberInfo, ) -from core.workflow.human_input_compat import ( +from core.workflow.human_input_adapter import ( EmailDeliveryConfig, EmailDeliveryMethod, EmailRecipients, diff --git a/api/tests/unit_tests/core/test_file.py b/api/tests/unit_tests/core/test_file.py index f17927f16b..eab0176f41 100644 --- a/api/tests/unit_tests/core/test_file.py +++ b/api/tests/unit_tests/core/test_file.py @@ -6,9 +6,9 @@ from models.workflow import Workflow def test_file_to_dict(): file = File( - id="file1", + file_id="file1", tenant_id="tenant1", - type=FileType.IMAGE, + file_type=FileType.IMAGE, transfer_method=FileTransferMethod.REMOTE_URL, remote_url="https://example.com/image1.jpg", storage_key="storage_key", diff --git a/api/tests/unit_tests/core/variables/test_segment.py b/api/tests/unit_tests/core/variables/test_segment.py index 72052c8c05..9e07ea1b6d 100644 --- a/api/tests/unit_tests/core/variables/test_segment.py +++ b/api/tests/unit_tests/core/variables/test_segment.py @@ -1,8 +1,9 @@ import dataclasses +from typing import Annotated import orjson import pytest -from pydantic import BaseModel +from pydantic import BaseModel, Discriminator, Tag from core.helper import encrypter from core.workflow.system_variables import build_bootstrap_variables, build_system_variables @@ -12,17 +13,18 @@ from graphon.runtime import VariablePool from graphon.variables.segment_group import SegmentGroup from graphon.variables.segments import ( ArrayAnySegment, + ArrayBooleanSegment, ArrayFileSegment, ArrayNumberSegment, ArrayObjectSegment, ArrayStringSegment, + BooleanSegment, FileSegment, FloatSegment, IntegerSegment, NoneSegment, ObjectSegment, Segment, - SegmentUnion, StringSegment, get_segment_discriminator, ) @@ -47,6 +49,26 @@ from graphon.variables.variables import ( StringVariable, Variable, ) +from models.utils.file_input_compat import rebuild_serialized_graph_files_without_lookup + +type SegmentUnion = Annotated[ + ( + Annotated[NoneSegment, Tag(SegmentType.NONE)] + | Annotated[StringSegment, Tag(SegmentType.STRING)] + | Annotated[FloatSegment, Tag(SegmentType.FLOAT)] + | Annotated[IntegerSegment, Tag(SegmentType.INTEGER)] + | Annotated[ObjectSegment, Tag(SegmentType.OBJECT)] + | Annotated[FileSegment, Tag(SegmentType.FILE)] + | Annotated[BooleanSegment, Tag(SegmentType.BOOLEAN)] + | Annotated[ArrayAnySegment, Tag(SegmentType.ARRAY_ANY)] + | Annotated[ArrayStringSegment, Tag(SegmentType.ARRAY_STRING)] + | Annotated[ArrayNumberSegment, Tag(SegmentType.ARRAY_NUMBER)] + | Annotated[ArrayObjectSegment, Tag(SegmentType.ARRAY_OBJECT)] + | Annotated[ArrayFileSegment, Tag(SegmentType.ARRAY_FILE)] + | Annotated[ArrayBooleanSegment, Tag(SegmentType.ARRAY_BOOLEAN)] + ), + Discriminator(get_segment_discriminator), +] def _build_variable_pool( @@ -123,7 +145,7 @@ def create_test_file( ) -> File: """Factory function to create File objects for testing""" return File( - type=file_type, + file_type=file_type, transfer_method=transfer_method, filename=filename, extension=extension, @@ -160,7 +182,7 @@ class TestSegmentDumpAndLoad: assert restored == model def test_all_segments_serialization(self): - """Test serialization/deserialization of all segment types""" + """Test file-aware segment serialization through Dify's model boundary.""" # Create one instance of each segment type test_file = create_test_file() @@ -181,7 +203,7 @@ class TestSegmentDumpAndLoad: # Test serialization and deserialization model = _Segments(segments=all_segments) json_str = model.model_dump_json() - loaded = _Segments.model_validate_json(json_str) + loaded = _Segments.model_validate(rebuild_serialized_graph_files_without_lookup(orjson.loads(json_str))) # Verify all segments are preserved assert len(loaded.segments) == len(all_segments) @@ -202,7 +224,7 @@ class TestSegmentDumpAndLoad: assert loaded_segment.value == original.value def test_all_variables_serialization(self): - """Test serialization/deserialization of all variable types""" + """Test file-aware variable serialization through Dify's model boundary.""" # Create one instance of each variable type test_file = create_test_file() @@ -223,7 +245,7 @@ class TestSegmentDumpAndLoad: # Test serialization and deserialization model = _Variables(variables=all_variables) json_str = model.model_dump_json() - loaded = _Variables.model_validate_json(json_str) + loaded = _Variables.model_validate(rebuild_serialized_graph_files_without_lookup(orjson.loads(json_str))) # Verify all variables are preserved assert len(loaded.variables) == len(all_variables) diff --git a/api/tests/unit_tests/core/variables/test_segment_type_validation.py b/api/tests/unit_tests/core/variables/test_segment_type_validation.py index 94e788edb2..317fe99d37 100644 --- a/api/tests/unit_tests/core/variables/test_segment_type_validation.py +++ b/api/tests/unit_tests/core/variables/test_segment_type_validation.py @@ -35,7 +35,7 @@ def create_test_file( """Factory function to create File objects for testing.""" return File( tenant_id="test-tenant", - type=file_type, + file_type=file_type, transfer_method=transfer_method, filename=filename, extension=extension, diff --git a/api/tests/unit_tests/core/workflow/graph_engine/test_mock_factory.py b/api/tests/unit_tests/core/workflow/graph_engine/test_mock_factory.py index 76b2984a4b..9f3e3b00b9 100644 --- a/api/tests/unit_tests/core/workflow/graph_engine/test_mock_factory.py +++ b/api/tests/unit_tests/core/workflow/graph_engine/test_mock_factory.py @@ -1,12 +1,13 @@ -""" -Mock node factory for testing workflows with third-party service dependencies. +"""Mock node factory for third-party-service workflow tests. -This module provides a MockNodeFactory that automatically detects and mocks nodes -requiring external services (LLM, Agent, Tool, Knowledge Retrieval, HTTP Request). +The factory follows the same config adaptation path as production +`DifyNodeFactory.create_node()`, but swaps selected node classes for mock +implementations before instantiation. """ from typing import TYPE_CHECKING, Any +from core.workflow.human_input_adapter import adapt_node_config_for_graph from core.workflow.node_factory import DifyNodeFactory from graphon.entities.graph_config import NodeConfigDict, NodeConfigDictAdapter from graphon.enums import BuiltinNodeTypes, NodeType @@ -82,20 +83,20 @@ class MockNodeFactory(DifyNodeFactory): :param node_config: Node configuration dictionary :return: Node instance (real or mocked) """ - typed_node_config = NodeConfigDictAdapter.validate_python(node_config) + typed_node_config = NodeConfigDictAdapter.validate_python(adapt_node_config_for_graph(node_config)) + node_id = typed_node_config["id"] node_data = typed_node_config["data"] node_type = node_data.type # Check if this node type should be mocked if node_type in self._mock_node_types: - node_id = typed_node_config["id"] - # Create mock node instance mock_class = self._mock_node_types[node_type] + resolved_node_data = self._validate_resolved_node_data(mock_class, node_data) if node_type == BuiltinNodeTypes.CODE: mock_instance = mock_class( - id=node_id, - config=typed_node_config, + node_id=node_id, + config=resolved_node_data, graph_init_params=self.graph_init_params, graph_runtime_state=self.graph_runtime_state, mock_config=self.mock_config, @@ -104,8 +105,8 @@ class MockNodeFactory(DifyNodeFactory): ) elif node_type == BuiltinNodeTypes.HTTP_REQUEST: mock_instance = mock_class( - id=node_id, - config=typed_node_config, + node_id=node_id, + config=resolved_node_data, graph_init_params=self.graph_init_params, graph_runtime_state=self.graph_runtime_state, mock_config=self.mock_config, @@ -120,8 +121,8 @@ class MockNodeFactory(DifyNodeFactory): BuiltinNodeTypes.PARAMETER_EXTRACTOR, }: mock_instance = mock_class( - id=node_id, - config=typed_node_config, + node_id=node_id, + config=resolved_node_data, graph_init_params=self.graph_init_params, graph_runtime_state=self.graph_runtime_state, mock_config=self.mock_config, @@ -130,8 +131,8 @@ class MockNodeFactory(DifyNodeFactory): ) else: mock_instance = mock_class( - id=node_id, - config=typed_node_config, + node_id=node_id, + config=resolved_node_data, graph_init_params=self.graph_init_params, graph_runtime_state=self.graph_runtime_state, mock_config=self.mock_config, @@ -140,7 +141,7 @@ class MockNodeFactory(DifyNodeFactory): return mock_instance # For non-mocked node types, use parent implementation - return super().create_node(typed_node_config) + return super().create_node(node_config) def should_mock_node(self, node_type: NodeType) -> bool: """ diff --git a/api/tests/unit_tests/core/workflow/graph_engine/test_mock_nodes.py b/api/tests/unit_tests/core/workflow/graph_engine/test_mock_nodes.py index 971b9b2bbf..f9819c47ec 100644 --- a/api/tests/unit_tests/core/workflow/graph_engine/test_mock_nodes.py +++ b/api/tests/unit_tests/core/workflow/graph_engine/test_mock_nodes.py @@ -55,13 +55,14 @@ class MockNodeMixin: def __init__( self, - id: str, - config: Mapping[str, Any], + node_id: str, + config: Any, + *, graph_init_params: "GraphInitParams", graph_runtime_state: "GraphRuntimeState", mock_config: Optional["MockConfig"] = None, **kwargs: Any, - ): + ) -> None: if isinstance(self, (LLMNode, QuestionClassifierNode, ParameterExtractorNode)): kwargs.setdefault("credentials_provider", MagicMock(spec=CredentialsProvider)) kwargs.setdefault("model_factory", MagicMock(spec=ModelFactory)) @@ -96,7 +97,7 @@ class MockNodeMixin: kwargs.setdefault("message_transformer", MagicMock()) super().__init__( - id=id, + node_id=node_id, config=config, graph_init_params=graph_init_params, graph_runtime_state=graph_runtime_state, diff --git a/api/tests/unit_tests/core/workflow/graph_engine/test_parallel_human_input_join_resume.py b/api/tests/unit_tests/core/workflow/graph_engine/test_parallel_human_input_join_resume.py index 55a329eba9..75bc6d05f7 100644 --- a/api/tests/unit_tests/core/workflow/graph_engine/test_parallel_human_input_join_resume.py +++ b/api/tests/unit_tests/core/workflow/graph_engine/test_parallel_human_input_join_resume.py @@ -139,8 +139,8 @@ def _build_graph(runtime_state: GraphRuntimeState, repo: HumanInputFormRepositor start_config = {"id": "start", "data": StartNodeData(title="Start", variables=[]).model_dump()} start_node = StartNode( - id=start_config["id"], - config=start_config, + node_id=start_config["id"], + config=StartNodeData(title="Start", variables=[]), graph_init_params=graph_init_params, graph_runtime_state=runtime_state, ) @@ -154,8 +154,8 @@ def _build_graph(runtime_state: GraphRuntimeState, repo: HumanInputFormRepositor human_a_config = {"id": "human_a", "data": human_data.model_dump()} human_a = HumanInputNode( - id=human_a_config["id"], - config=human_a_config, + node_id=human_a_config["id"], + config=human_data, graph_init_params=graph_init_params, graph_runtime_state=runtime_state, form_repository=repo, @@ -164,8 +164,8 @@ def _build_graph(runtime_state: GraphRuntimeState, repo: HumanInputFormRepositor human_b_config = {"id": "human_b", "data": human_data.model_dump()} human_b = HumanInputNode( - id=human_b_config["id"], - config=human_b_config, + node_id=human_b_config["id"], + config=human_data, graph_init_params=graph_init_params, graph_runtime_state=runtime_state, form_repository=repo, @@ -182,8 +182,8 @@ def _build_graph(runtime_state: GraphRuntimeState, repo: HumanInputFormRepositor ) end_config = {"id": "end", "data": end_data.model_dump()} end_node = EndNode( - id=end_config["id"], - config=end_config, + node_id=end_config["id"], + config=end_data, graph_init_params=graph_init_params, graph_runtime_state=runtime_state, ) diff --git a/api/tests/unit_tests/core/workflow/nodes/answer/test_answer.py b/api/tests/unit_tests/core/workflow/nodes/answer/test_answer.py index 9c0ad25b58..76b4cd1ef4 100644 --- a/api/tests/unit_tests/core/workflow/nodes/answer/test_answer.py +++ b/api/tests/unit_tests/core/workflow/nodes/answer/test_answer.py @@ -9,6 +9,7 @@ from extensions.ext_database import db from graphon.enums import WorkflowNodeExecutionStatus from graphon.graph import Graph from graphon.nodes.answer.answer_node import AnswerNode +from graphon.nodes.answer.entities import AnswerNodeData from graphon.runtime import GraphRuntimeState, VariablePool from tests.workflow_test_utils import build_test_graph_init_params @@ -66,20 +67,15 @@ def test_execute_answer(): graph = Graph.init(graph_config=graph_config, node_factory=node_factory, root_node_id="start") - node_config = { - "id": "answer", - "data": { - "title": "123", - "type": "answer", - "answer": "Today's weather is {{#start.weather#}}\n{{#llm.text#}}\n{{img}}\nFin.", - }, - } - node = AnswerNode( - id=str(uuid.uuid4()), + node_id=str(uuid.uuid4()), graph_init_params=init_params, graph_runtime_state=graph_runtime_state, - config=node_config, + config=AnswerNodeData( + title="123", + type="answer", + answer="Today's weather is {{#start.weather#}}\n{{#llm.text#}}\n{{img}}\nFin.", + ), ) # Mock db.session.close() diff --git a/api/tests/unit_tests/core/workflow/nodes/datasource/test_datasource_node.py b/api/tests/unit_tests/core/workflow/nodes/datasource/test_datasource_node.py index 9cceadde49..d7ef781732 100644 --- a/api/tests/unit_tests/core/workflow/nodes/datasource/test_datasource_node.py +++ b/api/tests/unit_tests/core/workflow/nodes/datasource/test_datasource_node.py @@ -1,5 +1,6 @@ from core.app.entities.app_invoke_entities import DIFY_RUN_CONTEXT_KEY from core.workflow.nodes.datasource.datasource_node import DatasourceNode +from core.workflow.nodes.datasource.entities import DatasourceNodeData from graphon.enums import WorkflowNodeExecutionStatus from graphon.node_events import NodeRunResult, StreamChunkEvent, StreamCompletedEvent @@ -77,19 +78,16 @@ def test_datasource_node_delegates_to_manager_stream(mocker): mocker.patch("core.workflow.nodes.datasource.datasource_node.DatasourceManager", new=_Mgr) node = DatasourceNode( - id="n", - config={ - "id": "n", - "data": { - "type": "datasource", - "version": "1", - "title": "Datasource", - "provider_type": "plugin", - "provider_name": "p", - "plugin_id": "plug", - "datasource_name": "ds", - }, - }, + node_id="n", + config=DatasourceNodeData( + type="datasource", + version="1", + title="Datasource", + provider_type="plugin", + provider_name="p", + plugin_id="plug", + datasource_name="ds", + ), graph_init_params=gp, graph_runtime_state=gs, ) diff --git a/api/tests/unit_tests/core/workflow/nodes/http_request/test_http_request_node.py b/api/tests/unit_tests/core/workflow/nodes/http_request/test_http_request_node.py index a3cadc0681..2e89a2da3c 100644 --- a/api/tests/unit_tests/core/workflow/nodes/http_request/test_http_request_node.py +++ b/api/tests/unit_tests/core/workflow/nodes/http_request/test_http_request_node.py @@ -12,7 +12,7 @@ from core.workflow.system_variables import build_system_variables from graphon.enums import WorkflowNodeExecutionStatus from graphon.file.file_manager import file_manager from graphon.nodes.http_request import HTTP_REQUEST_CONFIG_FILTER_KEY, HttpRequestNode, HttpRequestNodeConfig -from graphon.nodes.http_request.entities import HttpRequestNodeTimeout, Response +from graphon.nodes.http_request.entities import HttpRequestNodeData, HttpRequestNodeTimeout, Response from graphon.runtime import GraphRuntimeState, VariablePool from tests.workflow_test_utils import build_test_graph_init_params @@ -66,8 +66,8 @@ def test_get_default_config_uses_injected_http_request_config(): assert default_config["retry_config"]["max_retries"] == 7 -def test_get_default_config_with_malformed_http_request_config_raises_value_error(): - with pytest.raises(ValueError, match="http_request_config must be an HttpRequestNodeConfig instance"): +def test_get_default_config_with_malformed_http_request_config_raises_type_error(): + with pytest.raises(TypeError, match="http_request_config must be an HttpRequestNodeConfig instance"): HttpRequestNode.get_default_config(filters={HTTP_REQUEST_CONFIG_FILTER_KEY: "invalid"}) @@ -114,8 +114,8 @@ def _build_http_node( start_at=time.perf_counter(), ) return HttpRequestNode( - id="http-node", - config=node_config, + node_id="http-node", + config=HttpRequestNodeData.model_validate(node_data), graph_init_params=graph_init_params, graph_runtime_state=graph_runtime_state, http_request_config=HTTP_REQUEST_CONFIG, diff --git a/api/tests/unit_tests/core/workflow/nodes/human_input/test_email_delivery_config.py b/api/tests/unit_tests/core/workflow/nodes/human_input/test_email_delivery_config.py index 1d6a4da7c4..07430498e5 100644 --- a/api/tests/unit_tests/core/workflow/nodes/human_input/test_email_delivery_config.py +++ b/api/tests/unit_tests/core/workflow/nodes/human_input/test_email_delivery_config.py @@ -1,4 +1,4 @@ -from core.workflow.human_input_compat import EmailDeliveryConfig, EmailRecipients +from core.workflow.human_input_adapter import EmailDeliveryConfig, EmailRecipients from graphon.runtime import VariablePool diff --git a/api/tests/unit_tests/core/workflow/nodes/human_input/test_entities.py b/api/tests/unit_tests/core/workflow/nodes/human_input/test_entities.py index c0e21d0bf7..0659984c76 100644 --- a/api/tests/unit_tests/core/workflow/nodes/human_input/test_entities.py +++ b/api/tests/unit_tests/core/workflow/nodes/human_input/test_entities.py @@ -19,7 +19,7 @@ from core.repositories.human_input_repository import ( HumanInputFormRecipientEntity, HumanInputFormRepository, ) -from core.workflow.human_input_compat import ( +from core.workflow.human_input_adapter import ( DeliveryMethodType, EmailDeliveryConfig, EmailDeliveryMethod, @@ -136,6 +136,26 @@ class InMemoryHumanInputFormRepository(HumanInputFormRepository): entity.status_value = HumanInputFormStatus.SUBMITTED +def _build_human_input_node( + *, + node_id: str, + node_data: HumanInputNodeData | Mapping[str, Any], + graph_init_params: GraphInitParams, + graph_runtime_state: GraphRuntimeState, + runtime: DifyHumanInputNodeRuntime, +) -> HumanInputNode: + typed_node_data = ( + node_data if isinstance(node_data, HumanInputNodeData) else HumanInputNodeData.model_validate(node_data) + ) + return HumanInputNode( + node_id=node_id, + config=typed_node_data, + graph_init_params=graph_init_params, + graph_runtime_state=graph_runtime_state, + runtime=runtime, + ) + + class TestDeliveryMethod: """Test DeliveryMethod entity.""" @@ -239,7 +259,7 @@ class TestUserAction: data[field_name] = value with pytest.raises(ValidationError) as exc_info: - UserAction(**data) + UserAction.model_validate(data) errors = exc_info.value.errors() assert any(error["loc"] == (field_name,) and error["type"] == "string_too_long" for error in errors) @@ -465,9 +485,9 @@ class TestHumanInputNodeVariableResolution: runtime = DifyHumanInputNodeRuntime(graph_init_params.run_context) runtime._build_form_repository = MagicMock(return_value=mock_repo) # type: ignore[attr-defined] - node = HumanInputNode( - id=config["id"], - config=config, + node = _build_human_input_node( + node_id=config["id"], + node_data=config["data"], graph_init_params=graph_init_params, graph_runtime_state=runtime_state, runtime=runtime, @@ -530,9 +550,9 @@ class TestHumanInputNodeVariableResolution: runtime = DifyHumanInputNodeRuntime(graph_init_params.run_context) runtime._build_form_repository = MagicMock(return_value=mock_repo) # type: ignore[attr-defined] - node = HumanInputNode( - id=config["id"], - config=config, + node = _build_human_input_node( + node_id=config["id"], + node_data=config["data"], graph_init_params=graph_init_params, graph_runtime_state=runtime_state, runtime=runtime, @@ -595,9 +615,9 @@ class TestHumanInputNodeVariableResolution: runtime = DifyHumanInputNodeRuntime(graph_init_params.run_context) runtime._build_form_repository = MagicMock(return_value=mock_repo) # type: ignore[attr-defined] - node = HumanInputNode( - id=config["id"], - config=config, + node = _build_human_input_node( + node_id=config["id"], + node_data=config["data"], graph_init_params=graph_init_params, graph_runtime_state=runtime_state, runtime=runtime, @@ -671,9 +691,9 @@ class TestHumanInputNodeVariableResolution: runtime = DifyHumanInputNodeRuntime(graph_init_params.run_context) runtime._build_form_repository = MagicMock(return_value=mock_repo) # type: ignore[attr-defined] - node = HumanInputNode( - id=config["id"], - config=config, + node = _build_human_input_node( + node_id=config["id"], + node_data=config["data"], graph_init_params=graph_init_params, graph_runtime_state=runtime_state, runtime=runtime, @@ -770,9 +790,9 @@ class TestHumanInputNodeRenderedContent: form_repository = InMemoryHumanInputFormRepository() runtime = DifyHumanInputNodeRuntime(graph_init_params.run_context) runtime._build_form_repository = MagicMock(return_value=form_repository) # type: ignore[attr-defined] - node = HumanInputNode( - id=config["id"], - config=config, + node = _build_human_input_node( + node_id=config["id"], + node_data=config["data"], graph_init_params=graph_init_params, graph_runtime_state=runtime_state, runtime=runtime, diff --git a/api/tests/unit_tests/core/workflow/nodes/human_input/test_human_input_form_filled_event.py b/api/tests/unit_tests/core/workflow/nodes/human_input/test_human_input_form_filled_event.py index bc98028d5b..4a9438b14f 100644 --- a/api/tests/unit_tests/core/workflow/nodes/human_input/test_human_input_form_filled_event.py +++ b/api/tests/unit_tests/core/workflow/nodes/human_input/test_human_input_form_filled_event.py @@ -11,6 +11,7 @@ from graphon.graph_events import ( NodeRunHumanInputFormTimeoutEvent, NodeRunStartedEvent, ) +from graphon.nodes.human_input.entities import HumanInputNodeData from graphon.nodes.human_input.enums import HumanInputFormStatus from graphon.nodes.human_input.human_input_node import HumanInputNode from graphon.runtime import GraphRuntimeState, VariablePool @@ -25,6 +26,28 @@ class _FakeFormRepository: return self._form +def _create_human_input_node( + *, + config: dict, + graph_init_params: GraphInitParams, + graph_runtime_state: GraphRuntimeState, + repo: _FakeFormRepository, +) -> HumanInputNode: + node_data = ( + config["data"] + if isinstance(config["data"], HumanInputNodeData) + else HumanInputNodeData.model_validate(config["data"]) + ) + return HumanInputNode( + node_id=config["id"], + config=node_data, + graph_init_params=graph_init_params, + graph_runtime_state=graph_runtime_state, + form_repository=repo, + runtime=DifyHumanInputNodeRuntime(graph_init_params.run_context), + ) + + def _build_node(form_content: str = "Please enter your name:\n\n{{#$output.name#}}") -> HumanInputNode: system_variables = default_system_variables() graph_runtime_state = GraphRuntimeState( @@ -80,13 +103,11 @@ def _build_node(form_content: str = "Please enter your name:\n\n{{#$output.name# ) repo = _FakeFormRepository(fake_form) - return HumanInputNode( - id="node-1", + return _create_human_input_node( config=config, graph_init_params=graph_init_params, graph_runtime_state=graph_runtime_state, - form_repository=repo, - runtime=DifyHumanInputNodeRuntime(graph_init_params.run_context), + repo=repo, ) @@ -145,13 +166,11 @@ def _build_timeout_node() -> HumanInputNode: ) repo = _FakeFormRepository(fake_form) - return HumanInputNode( - id="node-1", + return _create_human_input_node( config=config, graph_init_params=graph_init_params, graph_runtime_state=graph_runtime_state, - form_repository=repo, - runtime=DifyHumanInputNodeRuntime(graph_init_params.run_context), + repo=repo, ) diff --git a/api/tests/unit_tests/core/workflow/nodes/iteration/test_iteration_child_engine_errors.py b/api/tests/unit_tests/core/workflow/nodes/iteration/test_iteration_child_engine_errors.py index 82cc734274..8ffce39cd6 100644 --- a/api/tests/unit_tests/core/workflow/nodes/iteration/test_iteration_child_engine_errors.py +++ b/api/tests/unit_tests/core/workflow/nodes/iteration/test_iteration_child_engine_errors.py @@ -5,6 +5,7 @@ import pytest from core.workflow.system_variables import default_system_variables from graphon.entities import GraphInitParams +from graphon.nodes.iteration.entities import IterationNodeData from graphon.nodes.iteration.exc import IterationGraphNotFoundError from graphon.nodes.iteration.iteration_node import IterationNode from graphon.runtime import ( @@ -44,17 +45,14 @@ def _build_iteration_node( ) -> IterationNode: init_params = build_test_graph_init_params(graph_config=graph_config) return IterationNode( - id="iteration-node", - config={ - "id": "iteration-node", - "data": { - "type": "iteration", - "title": "Iteration", - "iterator_selector": ["start", "items"], - "output_selector": ["iteration-node", "output"], - "start_node_id": start_node_id, - }, - }, + node_id="iteration-node", + config=IterationNodeData( + type="iteration", + title="Iteration", + iterator_selector=["start", "items"], + output_selector=["iteration-node", "output"], + start_node_id=start_node_id, + ), graph_init_params=init_params, graph_runtime_state=runtime_state, ) diff --git a/api/tests/unit_tests/core/workflow/nodes/knowledge_index/test_knowledge_index_node.py b/api/tests/unit_tests/core/workflow/nodes/knowledge_index/test_knowledge_index_node.py index a6fca1bfb4..f254fc3d09 100644 --- a/api/tests/unit_tests/core/workflow/nodes/knowledge_index/test_knowledge_index_node.py +++ b/api/tests/unit_tests/core/workflow/nodes/knowledge_index/test_knowledge_index_node.py @@ -93,6 +93,25 @@ def sample_chunks(): } +def _build_node( + *, + node_id: str, + node_data: KnowledgeIndexNodeData | dict[str, object], + graph_init_params, + graph_runtime_state, +) -> KnowledgeIndexNode: + return KnowledgeIndexNode( + node_id=node_id, + config=( + node_data + if isinstance(node_data, KnowledgeIndexNodeData) + else KnowledgeIndexNodeData.model_validate(node_data) + ), + graph_init_params=graph_init_params, + graph_runtime_state=graph_runtime_state, + ) + + class TestKnowledgeIndexNode: """ Test suite for KnowledgeIndexNode. @@ -115,9 +134,9 @@ class TestKnowledgeIndexNode: } # Act - node = KnowledgeIndexNode( - id=node_id, - config=config, + node = _build_node( + node_id=node_id, + node_data=config["data"], graph_init_params=mock_graph_init_params, graph_runtime_state=mock_graph_runtime_state, ) @@ -143,9 +162,9 @@ class TestKnowledgeIndexNode: "data": sample_node_data.model_dump(), } - node = KnowledgeIndexNode( - id=node_id, - config=config, + node = _build_node( + node_id=node_id, + node_data=config["data"], graph_init_params=mock_graph_init_params, graph_runtime_state=mock_graph_runtime_state, ) @@ -176,9 +195,9 @@ class TestKnowledgeIndexNode: "data": sample_node_data.model_dump(), } - node = KnowledgeIndexNode( - id=node_id, - config=config, + node = _build_node( + node_id=node_id, + node_data=config["data"], graph_init_params=mock_graph_init_params, graph_runtime_state=mock_graph_runtime_state, ) @@ -212,9 +231,9 @@ class TestKnowledgeIndexNode: "data": sample_node_data.model_dump(), } - node = KnowledgeIndexNode( - id=node_id, - config=config, + node = _build_node( + node_id=node_id, + node_data=config["data"], graph_init_params=mock_graph_init_params, graph_runtime_state=mock_graph_runtime_state, ) @@ -269,9 +288,9 @@ class TestKnowledgeIndexNode: "data": sample_node_data.model_dump(), } - node = KnowledgeIndexNode( - id=node_id, - config=config, + node = _build_node( + node_id=node_id, + node_data=config["data"], graph_init_params=mock_graph_init_params, graph_runtime_state=mock_graph_runtime_state, ) @@ -332,9 +351,9 @@ class TestKnowledgeIndexNode: "data": sample_node_data.model_dump(), } - node = KnowledgeIndexNode( - id=node_id, - config=config, + node = _build_node( + node_id=node_id, + node_data=config["data"], graph_init_params=mock_graph_init_params, graph_runtime_state=mock_graph_runtime_state, ) @@ -383,9 +402,9 @@ class TestKnowledgeIndexNode: "data": sample_node_data.model_dump(), } - node = KnowledgeIndexNode( - id=node_id, - config=config, + node = _build_node( + node_id=node_id, + node_data=config["data"], graph_init_params=mock_graph_init_params, graph_runtime_state=mock_graph_runtime_state, ) @@ -440,9 +459,9 @@ class TestKnowledgeIndexNode: "data": sample_node_data.model_dump(), } - node = KnowledgeIndexNode( - id=node_id, - config=config, + node = _build_node( + node_id=node_id, + node_data=config["data"], graph_init_params=mock_graph_init_params, graph_runtime_state=mock_graph_runtime_state, ) @@ -498,9 +517,9 @@ class TestKnowledgeIndexNode: "data": sample_node_data.model_dump(), } - node = KnowledgeIndexNode( - id=node_id, - config=config, + node = _build_node( + node_id=node_id, + node_data=config["data"], graph_init_params=mock_graph_init_params, graph_runtime_state=mock_graph_runtime_state, ) @@ -536,9 +555,9 @@ class TestKnowledgeIndexNode: "data": sample_node_data.model_dump(), } - node = KnowledgeIndexNode( - id=node_id, - config=config, + node = _build_node( + node_id=node_id, + node_data=config["data"], graph_init_params=mock_graph_init_params, graph_runtime_state=mock_graph_runtime_state, ) @@ -583,9 +602,9 @@ class TestKnowledgeIndexNode: "data": sample_node_data.model_dump(), } - node = KnowledgeIndexNode( - id=node_id, - config=config, + node = _build_node( + node_id=node_id, + node_data=config["data"], graph_init_params=mock_graph_init_params, graph_runtime_state=mock_graph_runtime_state, ) @@ -623,9 +642,9 @@ class TestInvokeKnowledgeIndex: "data": sample_node_data.model_dump(), } - node = KnowledgeIndexNode( - id=node_id, - config=config, + node = _build_node( + node_id=node_id, + node_data=config["data"], graph_init_params=mock_graph_init_params, graph_runtime_state=mock_graph_runtime_state, ) diff --git a/api/tests/unit_tests/core/workflow/nodes/knowledge_retrieval/test_knowledge_retrieval_node.py b/api/tests/unit_tests/core/workflow/nodes/knowledge_retrieval/test_knowledge_retrieval_node.py index 45e8ae7d20..e923ee761b 100644 --- a/api/tests/unit_tests/core/workflow/nodes/knowledge_retrieval/test_knowledge_retrieval_node.py +++ b/api/tests/unit_tests/core/workflow/nodes/knowledge_retrieval/test_knowledge_retrieval_node.py @@ -14,7 +14,11 @@ from core.workflow.nodes.knowledge_retrieval.entities import ( SingleRetrievalConfig, ) from core.workflow.nodes.knowledge_retrieval.exc import RateLimitExceededError -from core.workflow.nodes.knowledge_retrieval.knowledge_retrieval_node import KnowledgeRetrievalNode +from core.workflow.nodes.knowledge_retrieval.knowledge_retrieval_node import ( + KnowledgeRetrievalNode, + _normalize_metadata_filter_scalar, + _normalize_metadata_filter_sequence_item, +) from core.workflow.nodes.knowledge_retrieval.retrieval import RAGRetrievalProtocol, Source from core.workflow.system_variables import build_system_variables from graphon.enums import WorkflowNodeExecutionStatus @@ -85,6 +89,12 @@ def sample_node_data(): ) +def test_metadata_filter_normalizers_preserve_numeric_scalars_and_stringify_other_values() -> None: + assert _normalize_metadata_filter_scalar(3) == 3 + assert _normalize_metadata_filter_scalar(True) == "True" + assert _normalize_metadata_filter_sequence_item(4) == "4" + + class TestKnowledgeRetrievalNode: """ Test suite for KnowledgeRetrievalNode. @@ -106,8 +116,8 @@ class TestKnowledgeRetrievalNode: # Act node = KnowledgeRetrievalNode( - id=node_id, - config=config, + node_id=node_id, + config=KnowledgeRetrievalNodeData.model_validate(config["data"]), graph_init_params=mock_graph_init_params, graph_runtime_state=mock_graph_runtime_state, ) @@ -135,8 +145,8 @@ class TestKnowledgeRetrievalNode: } node = KnowledgeRetrievalNode( - id=node_id, - config=config, + node_id=node_id, + config=KnowledgeRetrievalNodeData.model_validate(config["data"]), graph_init_params=mock_graph_init_params, graph_runtime_state=mock_graph_runtime_state, ) @@ -194,8 +204,8 @@ class TestKnowledgeRetrievalNode: mock_rag_retrieval.llm_usage = LLMUsage.empty_usage() node = KnowledgeRetrievalNode( - id=node_id, - config=config, + node_id=node_id, + config=KnowledgeRetrievalNodeData.model_validate(config["data"]), graph_init_params=mock_graph_init_params, graph_runtime_state=mock_graph_runtime_state, ) @@ -238,8 +248,8 @@ class TestKnowledgeRetrievalNode: mock_rag_retrieval.llm_usage = LLMUsage.empty_usage() node = KnowledgeRetrievalNode( - id=node_id, - config=config, + node_id=node_id, + config=KnowledgeRetrievalNodeData.model_validate(config["data"]), graph_init_params=mock_graph_init_params, graph_runtime_state=mock_graph_runtime_state, ) @@ -274,8 +284,8 @@ class TestKnowledgeRetrievalNode: } node = KnowledgeRetrievalNode( - id=node_id, - config=config, + node_id=node_id, + config=KnowledgeRetrievalNodeData.model_validate(config["data"]), graph_init_params=mock_graph_init_params, graph_runtime_state=mock_graph_runtime_state, ) @@ -309,8 +319,8 @@ class TestKnowledgeRetrievalNode: } node = KnowledgeRetrievalNode( - id=node_id, - config=config, + node_id=node_id, + config=KnowledgeRetrievalNodeData.model_validate(config["data"]), graph_init_params=mock_graph_init_params, graph_runtime_state=mock_graph_runtime_state, ) @@ -350,8 +360,8 @@ class TestKnowledgeRetrievalNode: mock_rag_retrieval.llm_usage = LLMUsage.empty_usage() node = KnowledgeRetrievalNode( - id=node_id, - config=config, + node_id=node_id, + config=KnowledgeRetrievalNodeData.model_validate(config["data"]), graph_init_params=mock_graph_init_params, graph_runtime_state=mock_graph_runtime_state, ) @@ -389,8 +399,8 @@ class TestKnowledgeRetrievalNode: mock_rag_retrieval.llm_usage = LLMUsage.empty_usage() node = KnowledgeRetrievalNode( - id=node_id, - config=config, + node_id=node_id, + config=KnowledgeRetrievalNodeData.model_validate(config["data"]), graph_init_params=mock_graph_init_params, graph_runtime_state=mock_graph_runtime_state, ) @@ -470,8 +480,8 @@ class TestFetchDatasetRetriever: config = {"id": node_id, "data": node_data.model_dump()} node = KnowledgeRetrievalNode( - id=node_id, - config=config, + node_id=node_id, + config=KnowledgeRetrievalNodeData.model_validate(config["data"]), graph_init_params=mock_graph_init_params, graph_runtime_state=mock_graph_runtime_state, ) @@ -507,8 +517,8 @@ class TestFetchDatasetRetriever: } node = KnowledgeRetrievalNode( - id=node_id, - config=config, + node_id=node_id, + config=KnowledgeRetrievalNodeData.model_validate(config["data"]), graph_init_params=mock_graph_init_params, graph_runtime_state=mock_graph_runtime_state, ) @@ -562,8 +572,8 @@ class TestFetchDatasetRetriever: } node = KnowledgeRetrievalNode( - id=node_id, - config=config, + node_id=node_id, + config=KnowledgeRetrievalNodeData.model_validate(config["data"]), graph_init_params=mock_graph_init_params, graph_runtime_state=mock_graph_runtime_state, ) @@ -610,8 +620,8 @@ class TestFetchDatasetRetriever: mock_graph_runtime_state.variable_pool.add(["start", "query"], StringSegment(value="readme")) node = KnowledgeRetrievalNode( - id=node_id, - config=config, + node_id=node_id, + config=KnowledgeRetrievalNodeData.model_validate(config["data"]), graph_init_params=mock_graph_init_params, graph_runtime_state=mock_graph_runtime_state, ) @@ -671,8 +681,8 @@ class TestFetchDatasetRetriever: node_id = str(uuid.uuid4()) config = {"id": node_id, "data": node_data.model_dump()} node = KnowledgeRetrievalNode( - id=node_id, - config=config, + node_id=node_id, + config=KnowledgeRetrievalNodeData.model_validate(config["data"]), graph_init_params=mock_graph_init_params, graph_runtime_state=mock_graph_runtime_state, ) diff --git a/api/tests/unit_tests/core/workflow/nodes/list_operator/node_spec.py b/api/tests/unit_tests/core/workflow/nodes/list_operator/node_spec.py index eca34f05be..388654f279 100644 --- a/api/tests/unit_tests/core/workflow/nodes/list_operator/node_spec.py +++ b/api/tests/unit_tests/core/workflow/nodes/list_operator/node_spec.py @@ -1,3 +1,4 @@ +from types import SimpleNamespace from unittest.mock import MagicMock import pytest @@ -5,6 +6,7 @@ import pytest from core.app.entities.app_invoke_entities import DIFY_RUN_CONTEXT_KEY from graphon.entities import GraphInitParams from graphon.enums import BuiltinNodeTypes, WorkflowNodeExecutionStatus +from graphon.nodes.list_operator.entities import ListOperatorNodeData from graphon.nodes.list_operator.node import ListOperatorNode from graphon.runtime import GraphRuntimeState from graphon.variables import ArrayNumberSegment, ArrayStringSegment @@ -13,11 +15,28 @@ from graphon.variables import ArrayNumberSegment, ArrayStringSegment class TestListOperatorNode: """Comprehensive tests for ListOperatorNode.""" + @staticmethod + def _build_node(*, config, graph_init_params, graph_runtime_state): + return ListOperatorNode( + node_id="test", + config=config if isinstance(config, ListOperatorNodeData) else ListOperatorNodeData.model_validate(config), + graph_init_params=graph_init_params, + graph_runtime_state=graph_runtime_state, + ) + + @staticmethod + def _filter_by(comparison_operator: str, value: str) -> dict[str, object]: + return { + "enabled": True, + "conditions": [{"comparison_operator": comparison_operator, "value": value}], + } + @pytest.fixture def mock_graph_runtime_state(self): """Create mock GraphRuntimeState.""" mock_state = MagicMock(spec=GraphRuntimeState) mock_variable_pool = MagicMock() + mock_variable_pool.convert_template.side_effect = lambda value: SimpleNamespace(text=value) mock_state.variable_pool = mock_variable_pool return mock_state @@ -45,9 +64,8 @@ class TestListOperatorNode: def _create_node(config, mock_variable): mock_graph_runtime_state.variable_pool.get.return_value = mock_variable - return ListOperatorNode( - id="test", - config={"id": "test", "data": config}, + return self._build_node( + config=config, graph_init_params=graph_init_params, graph_runtime_state=mock_graph_runtime_state, ) @@ -64,9 +82,8 @@ class TestListOperatorNode: "limit": {"enabled": False}, } - node = ListOperatorNode( - id="test", - config={"id": "test", "data": config}, + node = self._build_node( + config=config, graph_init_params=graph_init_params, graph_runtime_state=mock_graph_runtime_state, ) @@ -109,9 +126,8 @@ class TestListOperatorNode: mock_var = ArrayStringSegment(value=[]) mock_graph_runtime_state.variable_pool.get.return_value = mock_var - node = ListOperatorNode( - id="test", - config={"id": "test", "data": config}, + node = self._build_node( + config=config, graph_init_params=graph_init_params, graph_runtime_state=mock_graph_runtime_state, ) @@ -128,11 +144,7 @@ class TestListOperatorNode: config = { "title": "Test", "variable": ["sys", "items"], - "filter_by": { - "enabled": True, - "condition": "contains", - "value": "app", - }, + "filter_by": self._filter_by("contains", "app"), "order_by": {"enabled": False}, "limit": {"enabled": False}, } @@ -140,9 +152,8 @@ class TestListOperatorNode: mock_var = ArrayStringSegment(value=["apple", "banana", "pineapple", "cherry"]) mock_graph_runtime_state.variable_pool.get.return_value = mock_var - node = ListOperatorNode( - id="test", - config={"id": "test", "data": config}, + node = self._build_node( + config=config, graph_init_params=graph_init_params, graph_runtime_state=mock_graph_runtime_state, ) @@ -157,11 +168,7 @@ class TestListOperatorNode: config = { "title": "Test", "variable": ["sys", "items"], - "filter_by": { - "enabled": True, - "condition": "not contains", - "value": "app", - }, + "filter_by": self._filter_by("not contains", "app"), "order_by": {"enabled": False}, "limit": {"enabled": False}, } @@ -169,9 +176,8 @@ class TestListOperatorNode: mock_var = ArrayStringSegment(value=["apple", "banana", "pineapple", "cherry"]) mock_graph_runtime_state.variable_pool.get.return_value = mock_var - node = ListOperatorNode( - id="test", - config={"id": "test", "data": config}, + node = self._build_node( + config=config, graph_init_params=graph_init_params, graph_runtime_state=mock_graph_runtime_state, ) @@ -186,11 +192,7 @@ class TestListOperatorNode: config = { "title": "Test", "variable": ["sys", "numbers"], - "filter_by": { - "enabled": True, - "condition": ">", - "value": "5", - }, + "filter_by": self._filter_by(">", "5"), "order_by": {"enabled": False}, "limit": {"enabled": False}, } @@ -198,9 +200,8 @@ class TestListOperatorNode: mock_var = ArrayNumberSegment(value=[1, 3, 5, 7, 9, 11]) mock_graph_runtime_state.variable_pool.get.return_value = mock_var - node = ListOperatorNode( - id="test", - config={"id": "test", "data": config}, + node = self._build_node( + config=config, graph_init_params=graph_init_params, graph_runtime_state=mock_graph_runtime_state, ) @@ -226,9 +227,8 @@ class TestListOperatorNode: mock_var = ArrayStringSegment(value=["cherry", "apple", "banana"]) mock_graph_runtime_state.variable_pool.get.return_value = mock_var - node = ListOperatorNode( - id="test", - config={"id": "test", "data": config}, + node = self._build_node( + config=config, graph_init_params=graph_init_params, graph_runtime_state=mock_graph_runtime_state, ) @@ -254,9 +254,8 @@ class TestListOperatorNode: mock_var = ArrayStringSegment(value=["cherry", "apple", "banana"]) mock_graph_runtime_state.variable_pool.get.return_value = mock_var - node = ListOperatorNode( - id="test", - config={"id": "test", "data": config}, + node = self._build_node( + config=config, graph_init_params=graph_init_params, graph_runtime_state=mock_graph_runtime_state, ) @@ -282,9 +281,8 @@ class TestListOperatorNode: mock_var = ArrayStringSegment(value=["apple", "banana", "cherry", "date"]) mock_graph_runtime_state.variable_pool.get.return_value = mock_var - node = ListOperatorNode( - id="test", - config={"id": "test", "data": config}, + node = self._build_node( + config=config, graph_init_params=graph_init_params, graph_runtime_state=mock_graph_runtime_state, ) @@ -299,11 +297,7 @@ class TestListOperatorNode: config = { "title": "Test", "variable": ["sys", "numbers"], - "filter_by": { - "enabled": True, - "condition": ">", - "value": "3", - }, + "filter_by": self._filter_by(">", "3"), "order_by": { "enabled": True, "value": "desc", @@ -317,9 +311,8 @@ class TestListOperatorNode: mock_var = ArrayNumberSegment(value=[1, 2, 3, 4, 5, 6, 7, 8, 9]) mock_graph_runtime_state.variable_pool.get.return_value = mock_var - node = ListOperatorNode( - id="test", - config={"id": "test", "data": config}, + node = self._build_node( + config=config, graph_init_params=graph_init_params, graph_runtime_state=mock_graph_runtime_state, ) @@ -341,9 +334,8 @@ class TestListOperatorNode: mock_graph_runtime_state.variable_pool.get.return_value = None - node = ListOperatorNode( - id="test", - config={"id": "test", "data": config}, + node = self._build_node( + config=config, graph_init_params=graph_init_params, graph_runtime_state=mock_graph_runtime_state, ) @@ -366,9 +358,8 @@ class TestListOperatorNode: mock_var = ArrayStringSegment(value=["first", "middle", "last"]) mock_graph_runtime_state.variable_pool.get.return_value = mock_var - node = ListOperatorNode( - id="test", - config={"id": "test", "data": config}, + node = self._build_node( + config=config, graph_init_params=graph_init_params, graph_runtime_state=mock_graph_runtime_state, ) @@ -384,11 +375,7 @@ class TestListOperatorNode: config = { "title": "Test", "variable": ["sys", "items"], - "filter_by": { - "enabled": True, - "condition": "start with", - "value": "app", - }, + "filter_by": self._filter_by("start with", "app"), "order_by": {"enabled": False}, "limit": {"enabled": False}, } @@ -396,9 +383,8 @@ class TestListOperatorNode: mock_var = ArrayStringSegment(value=["apple", "application", "banana", "apricot"]) mock_graph_runtime_state.variable_pool.get.return_value = mock_var - node = ListOperatorNode( - id="test", - config={"id": "test", "data": config}, + node = self._build_node( + config=config, graph_init_params=graph_init_params, graph_runtime_state=mock_graph_runtime_state, ) @@ -413,11 +399,7 @@ class TestListOperatorNode: config = { "title": "Test", "variable": ["sys", "items"], - "filter_by": { - "enabled": True, - "condition": "end with", - "value": "le", - }, + "filter_by": self._filter_by("end with", "le"), "order_by": {"enabled": False}, "limit": {"enabled": False}, } @@ -425,9 +407,8 @@ class TestListOperatorNode: mock_var = ArrayStringSegment(value=["apple", "banana", "pineapple", "table"]) mock_graph_runtime_state.variable_pool.get.return_value = mock_var - node = ListOperatorNode( - id="test", - config={"id": "test", "data": config}, + node = self._build_node( + config=config, graph_init_params=graph_init_params, graph_runtime_state=mock_graph_runtime_state, ) @@ -442,11 +423,7 @@ class TestListOperatorNode: config = { "title": "Test", "variable": ["sys", "numbers"], - "filter_by": { - "enabled": True, - "condition": "=", - "value": "5", - }, + "filter_by": self._filter_by("=", "5"), "order_by": {"enabled": False}, "limit": {"enabled": False}, } @@ -454,9 +431,8 @@ class TestListOperatorNode: mock_var = ArrayNumberSegment(value=[1, 3, 5, 5, 7, 9]) mock_graph_runtime_state.variable_pool.get.return_value = mock_var - node = ListOperatorNode( - id="test", - config={"id": "test", "data": config}, + node = self._build_node( + config=config, graph_init_params=graph_init_params, graph_runtime_state=mock_graph_runtime_state, ) @@ -471,11 +447,7 @@ class TestListOperatorNode: config = { "title": "Test", "variable": ["sys", "numbers"], - "filter_by": { - "enabled": True, - "condition": "≠", - "value": "5", - }, + "filter_by": self._filter_by("≠", "5"), "order_by": {"enabled": False}, "limit": {"enabled": False}, } @@ -483,9 +455,8 @@ class TestListOperatorNode: mock_var = ArrayNumberSegment(value=[1, 3, 5, 7, 9]) mock_graph_runtime_state.variable_pool.get.return_value = mock_var - node = ListOperatorNode( - id="test", - config={"id": "test", "data": config}, + node = self._build_node( + config=config, graph_init_params=graph_init_params, graph_runtime_state=mock_graph_runtime_state, ) @@ -511,9 +482,8 @@ class TestListOperatorNode: mock_var = ArrayNumberSegment(value=[9, 3, 7, 1, 5]) mock_graph_runtime_state.variable_pool.get.return_value = mock_var - node = ListOperatorNode( - id="test", - config={"id": "test", "data": config}, + node = self._build_node( + config=config, graph_init_params=graph_init_params, graph_runtime_state=mock_graph_runtime_state, ) diff --git a/api/tests/unit_tests/core/workflow/nodes/llm/test_llm_utils.py b/api/tests/unit_tests/core/workflow/nodes/llm/test_llm_utils.py index 4186bbdc93..212ad07bd3 100644 --- a/api/tests/unit_tests/core/workflow/nodes/llm/test_llm_utils.py +++ b/api/tests/unit_tests/core/workflow/nodes/llm/test_llm_utils.py @@ -71,8 +71,8 @@ def _build_image_file( mime_type: str = "image/png", ) -> File: return File( - id=file_id, - type=FileType.IMAGE, + file_id=file_id, + file_type=FileType.IMAGE, filename=f"{file_id}{extension}", transfer_method=FileTransferMethod.REMOTE_URL, remote_url=remote_url, @@ -95,6 +95,8 @@ def variable_pool() -> VariablePool: def _fetch_prompt_messages_with_mocked_content(content): variable_pool = VariablePool.empty() model_instance = mock.MagicMock(spec=ModelInstance) + model_schema = mock.MagicMock() + model_schema.supports_prompt_content_type.side_effect = lambda content_type: content_type == "text" prompt_template = [ LLMNodeChatModelMessage( text="You are a classifier.", @@ -106,7 +108,7 @@ def _fetch_prompt_messages_with_mocked_content(content): with ( mock.patch( "graphon.nodes.llm.llm_utils.fetch_model_schema", - return_value=mock.MagicMock(features=[]), + return_value=model_schema, ), mock.patch( "graphon.nodes.llm.llm_utils.handle_list_messages", diff --git a/api/tests/unit_tests/core/workflow/nodes/llm/test_node.py b/api/tests/unit_tests/core/workflow/nodes/llm/test_node.py index b1f81b6c48..c707cf28cd 100644 --- a/api/tests/unit_tests/core/workflow/nodes/llm/test_node.py +++ b/api/tests/unit_tests/core/workflow/nodes/llm/test_node.py @@ -140,8 +140,8 @@ def _build_image_file( mime_type: str = "image/png", ) -> File: return File( - id=file_id, - type=FileType.IMAGE, + file_id=file_id, + file_type=FileType.IMAGE, filename=f"{file_id}{extension}", transfer_method=FileTransferMethod.REMOTE_URL, remote_url=remote_url, @@ -205,14 +205,10 @@ def llm_node( mock_credentials_provider = mock.MagicMock(spec=CredentialsProvider) mock_model_factory = mock.MagicMock(spec=ModelFactory) mock_prompt_message_serializer = mock.MagicMock(spec=PromptMessageSerializerProtocol) - node_config = { - "id": "1", - "data": llm_node_data.model_dump(), - } http_client = mock.MagicMock() node = LLMNode( - id="1", - config=node_config, + node_id="1", + config=llm_node_data, graph_init_params=graph_init_params, graph_runtime_state=graph_runtime_state, credentials_provider=mock_credentials_provider, @@ -403,8 +399,8 @@ def test_dify_model_access_adapters_call_managers(): def test_fetch_files_with_file_segment(): file = File( - id="1", - type=FileType.IMAGE, + file_id="1", + file_type=FileType.IMAGE, filename="test.jpg", transfer_method=FileTransferMethod.LOCAL_FILE, related_id="1", @@ -420,16 +416,16 @@ def test_fetch_files_with_file_segment(): def test_fetch_files_with_array_file_segment(): files = [ File( - id="1", - type=FileType.IMAGE, + file_id="1", + file_type=FileType.IMAGE, filename="test1.jpg", transfer_method=FileTransferMethod.LOCAL_FILE, related_id="1", storage_key="", ), File( - id="2", - type=FileType.IMAGE, + file_id="2", + file_type=FileType.IMAGE, filename="test2.jpg", transfer_method=FileTransferMethod.LOCAL_FILE, related_id="2", @@ -1174,14 +1170,10 @@ def llm_node_for_multimodal(llm_node_data, graph_init_params, graph_runtime_stat mock_credentials_provider = mock.MagicMock(spec=CredentialsProvider) mock_model_factory = mock.MagicMock(spec=ModelFactory) mock_prompt_message_serializer = mock.MagicMock(spec=PromptMessageSerializerProtocol) - node_config = { - "id": "1", - "data": llm_node_data.model_dump(), - } http_client = mock.MagicMock() node = LLMNode( - id="1", - config=node_config, + node_id="1", + config=llm_node_data, graph_init_params=graph_init_params, graph_runtime_state=graph_runtime_state, credentials_provider=mock_credentials_provider, @@ -1203,8 +1195,8 @@ class TestLLMNodeSaveMultiModalImageOutput: mime_type="image/png", ) mock_file = File( - id=str(uuid.uuid4()), - type=FileType.IMAGE, + file_id=str(uuid.uuid4()), + file_type=FileType.IMAGE, transfer_method=FileTransferMethod.TOOL_FILE, related_id=str(uuid.uuid4()), filename="test-file.png", @@ -1233,8 +1225,8 @@ class TestLLMNodeSaveMultiModalImageOutput: mime_type="image/jpg", ) mock_file = File( - id=str(uuid.uuid4()), - type=FileType.IMAGE, + file_id=str(uuid.uuid4()), + file_type=FileType.IMAGE, transfer_method=FileTransferMethod.TOOL_FILE, related_id=str(uuid.uuid4()), filename="test-file.png", @@ -1291,8 +1283,8 @@ class TestSaveMultimodalOutputAndConvertResultToMarkdown: image_b64_data = base64.b64encode(image_raw_data).decode() mock_saved_file = File( - id=str(uuid.uuid4()), - type=FileType.IMAGE, + file_id=str(uuid.uuid4()), + file_type=FileType.IMAGE, transfer_method=FileTransferMethod.TOOL_FILE, filename="test.png", extension=".png", @@ -1457,7 +1449,6 @@ def test_invoke_llm_dispatches_to_expected_model_method(structured_output_enable file_saver=file_saver, file_outputs=[], node_id="node-1", - node_type=LLMNode.node_type, reasoning_format="separated", ) ) @@ -1514,7 +1505,6 @@ def test_handle_invoke_result_streaming_collects_text_metrics_and_structured_out file_saver=mock.MagicMock(spec=LLMFileSaver), file_outputs=[], node_id="node-1", - node_type=LLMNode.node_type, model_instance=_build_prepared_llm_mock(), reasoning_format="separated", request_start_time=1.0, @@ -1552,7 +1542,6 @@ def test_handle_invoke_result_wraps_structured_output_parse_errors(): file_saver=mock.MagicMock(spec=LLMFileSaver), file_outputs=[], node_id="node-1", - node_type=LLMNode.node_type, model_instance=model_instance, ) ) diff --git a/api/tests/unit_tests/core/workflow/nodes/template_transform/template_transform_node_spec.py b/api/tests/unit_tests/core/workflow/nodes/template_transform/template_transform_node_spec.py index bc44ececd8..892f6cc586 100644 --- a/api/tests/unit_tests/core/workflow/nodes/template_transform/template_transform_node_spec.py +++ b/api/tests/unit_tests/core/workflow/nodes/template_transform/template_transform_node_spec.py @@ -13,6 +13,28 @@ from graphon.template_rendering import TemplateRenderError from tests.workflow_test_utils import build_test_graph_init_params +def _build_template_transform_node( + *, + node_data, + graph_init_params, + graph_runtime_state, + node_id: str = "test_node", + **kwargs, +) -> TemplateTransformNode: + typed_node_data = ( + node_data + if isinstance(node_data, TemplateTransformNodeData) + else TemplateTransformNodeData.model_validate(node_data) + ) + return TemplateTransformNode( + node_id=node_id, + config=typed_node_data, + graph_init_params=graph_init_params, + graph_runtime_state=graph_runtime_state, + **kwargs, + ) + + class TestTemplateTransformNode: """Comprehensive test suite for TemplateTransformNode.""" @@ -59,9 +81,8 @@ class TestTemplateTransformNode: def test_node_initialization(self, basic_node_data, mock_graph_runtime_state, graph_init_params): """Test that TemplateTransformNode initializes correctly.""" mock_renderer = MagicMock() - node = TemplateTransformNode( - id="test_node", - config={"id": "test_node", "data": basic_node_data}, + node = _build_template_transform_node( + node_data=basic_node_data, graph_init_params=graph_init_params, graph_runtime_state=mock_graph_runtime_state, jinja2_template_renderer=mock_renderer, @@ -75,9 +96,8 @@ class TestTemplateTransformNode: def test_get_title(self, basic_node_data, mock_graph_runtime_state, graph_init_params): """Test _get_title method.""" mock_renderer = MagicMock() - node = TemplateTransformNode( - id="test_node", - config={"id": "test_node", "data": basic_node_data}, + node = _build_template_transform_node( + node_data=basic_node_data, graph_init_params=graph_init_params, graph_runtime_state=mock_graph_runtime_state, jinja2_template_renderer=mock_renderer, @@ -88,9 +108,8 @@ class TestTemplateTransformNode: def test_get_description(self, basic_node_data, mock_graph_runtime_state, graph_init_params): """Test _get_description method.""" mock_renderer = MagicMock() - node = TemplateTransformNode( - id="test_node", - config={"id": "test_node", "data": basic_node_data}, + node = _build_template_transform_node( + node_data=basic_node_data, graph_init_params=graph_init_params, graph_runtime_state=mock_graph_runtime_state, jinja2_template_renderer=mock_renderer, @@ -108,9 +127,8 @@ class TestTemplateTransformNode: } mock_renderer = MagicMock() - node = TemplateTransformNode( - id="test_node", - config={"id": "test_node", "data": node_data}, + node = _build_template_transform_node( + node_data=node_data, graph_init_params=graph_init_params, graph_runtime_state=mock_graph_runtime_state, jinja2_template_renderer=mock_renderer, @@ -143,9 +161,8 @@ class TestTemplateTransformNode: mock_renderer = MagicMock() with pytest.raises(ValueError, match="max_output_length must be a positive integer"): - TemplateTransformNode( - id="test_node", - config={"id": "test_node", "data": basic_node_data}, + _build_template_transform_node( + node_data=basic_node_data, graph_init_params=graph_init_params, graph_runtime_state=mock_graph_runtime_state, jinja2_template_renderer=mock_renderer, @@ -170,9 +187,8 @@ class TestTemplateTransformNode: mock_renderer = MagicMock() mock_renderer.render_template.return_value = "Hello Alice, you are 30 years old!" - node = TemplateTransformNode( - id="test_node", - config={"id": "test_node", "data": basic_node_data}, + node = _build_template_transform_node( + node_data=basic_node_data, graph_init_params=graph_init_params, graph_runtime_state=mock_graph_runtime_state, jinja2_template_renderer=mock_renderer, @@ -198,9 +214,8 @@ class TestTemplateTransformNode: mock_renderer = MagicMock() mock_renderer.render_template.return_value = "Value: " - node = TemplateTransformNode( - id="test_node", - config={"id": "test_node", "data": node_data}, + node = _build_template_transform_node( + node_data=node_data, graph_init_params=graph_init_params, graph_runtime_state=mock_graph_runtime_state, jinja2_template_renderer=mock_renderer, @@ -218,9 +233,8 @@ class TestTemplateTransformNode: mock_renderer = MagicMock() mock_renderer.render_template.side_effect = TemplateRenderError("Template syntax error") - node = TemplateTransformNode( - id="test_node", - config={"id": "test_node", "data": basic_node_data}, + node = _build_template_transform_node( + node_data=basic_node_data, graph_init_params=graph_init_params, graph_runtime_state=mock_graph_runtime_state, jinja2_template_renderer=mock_renderer, @@ -238,9 +252,8 @@ class TestTemplateTransformNode: mock_renderer = MagicMock() mock_renderer.render_template.return_value = "This is a very long output that exceeds the limit" - node = TemplateTransformNode( - id="test_node", - config={"id": "test_node", "data": basic_node_data}, + node = _build_template_transform_node( + node_data=basic_node_data, graph_init_params=graph_init_params, graph_runtime_state=mock_graph_runtime_state, jinja2_template_renderer=mock_renderer, @@ -260,9 +273,8 @@ class TestTemplateTransformNode: mock_renderer = MagicMock() mock_renderer.render_template.return_value = "1234567890" - node = TemplateTransformNode( - id="test_node", - config={"id": "test_node", "data": basic_node_data}, + node = _build_template_transform_node( + node_data=basic_node_data, graph_init_params=graph_init_params, graph_runtime_state=mock_graph_runtime_state, jinja2_template_renderer=mock_renderer, @@ -302,9 +314,8 @@ class TestTemplateTransformNode: mock_renderer = MagicMock() mock_renderer.render_template.return_value = "apple, banana, orange (Total: 3)" - node = TemplateTransformNode( - id="test_node", - config={"id": "test_node", "data": node_data}, + node = _build_template_transform_node( + node_data=node_data, graph_init_params=graph_init_params, graph_runtime_state=mock_graph_runtime_state, jinja2_template_renderer=mock_renderer, @@ -375,8 +386,8 @@ class TestTemplateTransformNode: ) assert mapping == { - "node_123.var1": ["sys", "input1"], - "node_123.empty_selector": [], + "node_123.var1": ("sys", "input1"), + "node_123.empty_selector": (), } def test_extract_variable_selector_to_variable_mapping_ignores_invalid_entries(self): @@ -409,9 +420,8 @@ class TestTemplateTransformNode: mock_renderer = MagicMock() mock_renderer.render_template.return_value = "This is a static message." - node = TemplateTransformNode( - id="test_node", - config={"id": "test_node", "data": node_data}, + node = _build_template_transform_node( + node_data=node_data, graph_init_params=graph_init_params, graph_runtime_state=mock_graph_runtime_state, jinja2_template_renderer=mock_renderer, @@ -448,9 +458,8 @@ class TestTemplateTransformNode: mock_renderer = MagicMock() mock_renderer.render_template.return_value = "Total: $31.5" - node = TemplateTransformNode( - id="test_node", - config={"id": "test_node", "data": node_data}, + node = _build_template_transform_node( + node_data=node_data, graph_init_params=graph_init_params, graph_runtime_state=mock_graph_runtime_state, jinja2_template_renderer=mock_renderer, @@ -477,9 +486,8 @@ class TestTemplateTransformNode: mock_renderer = MagicMock() mock_renderer.render_template.return_value = "Name: John Doe, Email: john@example.com" - node = TemplateTransformNode( - id="test_node", - config={"id": "test_node", "data": node_data}, + node = _build_template_transform_node( + node_data=node_data, graph_init_params=graph_init_params, graph_runtime_state=mock_graph_runtime_state, jinja2_template_renderer=mock_renderer, @@ -507,9 +515,8 @@ class TestTemplateTransformNode: mock_renderer = MagicMock() mock_renderer.render_template.return_value = "Tags: #python #ai #workflow " - node = TemplateTransformNode( - id="test_node", - config={"id": "test_node", "data": node_data}, + node = _build_template_transform_node( + node_data=node_data, graph_init_params=graph_init_params, graph_runtime_state=mock_graph_runtime_state, jinja2_template_renderer=mock_renderer, diff --git a/api/tests/unit_tests/core/workflow/nodes/template_transform/test_template_transform_node.py b/api/tests/unit_tests/core/workflow/nodes/template_transform/test_template_transform_node.py index 636237e56e..a846efbb43 100644 --- a/api/tests/unit_tests/core/workflow/nodes/template_transform/test_template_transform_node.py +++ b/api/tests/unit_tests/core/workflow/nodes/template_transform/test_template_transform_node.py @@ -4,6 +4,7 @@ import pytest from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom from graphon.nodes.base.entities import VariableSelector +from graphon.nodes.template_transform.entities import TemplateTransformNodeData from graphon.nodes.template_transform.template_transform_node import ( DEFAULT_TEMPLATE_TRANSFORM_MAX_OUTPUT_LENGTH, TemplateTransformNode, @@ -37,15 +38,13 @@ def mock_graph_runtime_state(): def test_node_uses_default_max_output_length_when_not_overridden(graph_init_params, mock_graph_runtime_state): node = TemplateTransformNode( - id="test_node", - config={ - "id": "test_node", - "data": { - "title": "Template Transform", - "variables": [], - "template": "hello", - }, - }, + node_id="test_node", + config=TemplateTransformNodeData( + title="Template Transform", + type="template-transform", + variables=[], + template="hello", + ), graph_init_params=graph_init_params, graph_runtime_state=mock_graph_runtime_state, jinja2_template_renderer=MagicMock(), @@ -70,5 +69,5 @@ def test_extract_variable_selector_to_variable_mapping_accepts_mixed_valid_entri assert mapping == { "node_123.validated": ["sys", "input1"], - "node_123.raw": ["sys", "input2"], + "node_123.raw": ("sys", "input2"), } diff --git a/api/tests/unit_tests/core/workflow/nodes/test_base_node.py b/api/tests/unit_tests/core/workflow/nodes/test_base_node.py index 0522dd9d14..364408ead6 100644 --- a/api/tests/unit_tests/core/workflow/nodes/test_base_node.py +++ b/api/tests/unit_tests/core/workflow/nodes/test_base_node.py @@ -7,7 +7,6 @@ from core.workflow.node_runtime import resolve_dify_run_context from core.workflow.system_variables import build_system_variables from graphon.entities import GraphInitParams from graphon.entities.base_node_data import BaseNodeData -from graphon.entities.graph_config import NodeConfigDict, NodeConfigDictAdapter from graphon.enums import BuiltinNodeTypes from graphon.nodes.base.node import Node from graphon.runtime import GraphRuntimeState, VariablePool @@ -42,17 +41,19 @@ def _build_context(graph_config: Mapping[str, object]) -> tuple[GraphInitParams, return init_params, runtime_state -def _build_node_config() -> NodeConfigDict: - return NodeConfigDictAdapter.validate_python( - { - "id": "node-1", - "data": { - "type": BuiltinNodeTypes.ANSWER, - "title": "Sample", - "foo": "bar", - }, - } - ) +def _build_node_config() -> dict[str, object]: + return { + "id": "node-1", + "data": _SampleNodeData( + type=BuiltinNodeTypes.ANSWER, + title="Sample", + foo="bar", + ), + } + + +def _build_node_data() -> _SampleNodeData: + return _build_node_config()["data"] # type: ignore[return-value] def test_node_hydrates_data_during_initialization(): @@ -60,8 +61,8 @@ def test_node_hydrates_data_during_initialization(): init_params, runtime_state = _build_context(graph_config) node = _SampleNode( - id="node-1", - config=_build_node_config(), + node_id="node-1", + config=_build_node_data(), graph_init_params=init_params, graph_runtime_state=runtime_state, ) @@ -86,8 +87,8 @@ def test_node_accepts_invoke_from_enum(): ) node = _SampleNode( - id="node-1", - config=_build_node_config(), + node_id="node-1", + config=_build_node_data(), graph_init_params=init_params, graph_runtime_state=runtime_state, ) @@ -117,13 +118,7 @@ def test_missing_generic_argument_raises_type_error(): def test_base_node_data_keeps_dict_style_access_compatibility(): - node_data = _SampleNodeData.model_validate( - { - "type": BuiltinNodeTypes.ANSWER, - "title": "Sample", - "foo": "bar", - } - ) + node_data = _SampleNodeData(type=BuiltinNodeTypes.ANSWER, title="Sample", foo="bar") assert node_data["foo"] == "bar" assert node_data.get("foo") == "bar" @@ -133,21 +128,19 @@ def test_base_node_data_keeps_dict_style_access_compatibility(): def test_node_hydration_preserves_compatibility_extra_fields(): graph_config: dict[str, object] = {} init_params, runtime_state = _build_context(graph_config) - node_config = NodeConfigDictAdapter.validate_python( - { - "id": "node-1", - "data": { - "type": BuiltinNodeTypes.ANSWER, - "title": "Sample", - "foo": "bar", - "compat_flag": True, - }, - } - ) + node_config = { + "id": "node-1", + "data": _SampleNodeData( + type=BuiltinNodeTypes.ANSWER, + title="Sample", + foo="bar", + compat_flag=True, + ), + } node = _SampleNode( - id="node-1", - config=node_config, + node_id="node-1", + config=node_config["data"], graph_init_params=init_params, graph_runtime_state=runtime_state, ) diff --git a/api/tests/unit_tests/core/workflow/nodes/test_document_extractor_node.py b/api/tests/unit_tests/core/workflow/nodes/test_document_extractor_node.py index 87ec2d5bce..dd75b32593 100644 --- a/api/tests/unit_tests/core/workflow/nodes/test_document_extractor_node.py +++ b/api/tests/unit_tests/core/workflow/nodes/test_document_extractor_node.py @@ -11,14 +11,16 @@ from graphon.enums import BuiltinNodeTypes, WorkflowNodeExecutionStatus from graphon.file import File, FileTransferMethod from graphon.node_events import NodeRunResult from graphon.nodes.document_extractor import DocumentExtractorNode, DocumentExtractorNodeData +from graphon.nodes.document_extractor.exc import TextExtractionError, UnsupportedFileTypeError from graphon.nodes.document_extractor.node import ( _extract_text_from_docx, _extract_text_from_excel, + _extract_text_from_file, _extract_text_from_pdf, _extract_text_from_plain_text, _normalize_docx_zip, ) -from graphon.variables import ArrayFileSegment +from graphon.variables import ArrayFileSegment, FileSegment from graphon.variables.segments import ArrayStringSegment from graphon.variables.variables import StringVariable from tests.workflow_test_utils import build_test_graph_init_params @@ -44,11 +46,10 @@ def document_extractor_node(graph_init_params): title="Test Document Extractor", variable_selector=["node_id", "variable_name"], ) - node_config = {"id": "test_node_id", "data": node_data.model_dump()} http_client = Mock() node = DocumentExtractorNode( - id="test_node_id", - config=node_config, + node_id="test_node_id", + config=node_data, graph_init_params=graph_init_params, graph_runtime_state=Mock(), http_client=http_client, @@ -341,7 +342,7 @@ def test_extract_text_from_excel_sheet_parse_error(mock_excel_file): # Mock ExcelFile mock_excel_instance = Mock() mock_excel_instance.sheet_names = ["GoodSheet", "BadSheet"] - mock_excel_instance.parse.side_effect = [df, Exception("Parse error")] + mock_excel_instance.parse.side_effect = [df, TypeError("Parse error")] mock_excel_file.return_value = mock_excel_instance file_content = b"fake_excel_mixed_content" @@ -386,7 +387,7 @@ def test_extract_text_from_excel_all_sheets_fail(mock_excel_file): # Mock ExcelFile mock_excel_instance = Mock() mock_excel_instance.sheet_names = ["BadSheet1", "BadSheet2"] - mock_excel_instance.parse.side_effect = [Exception("Error 1"), Exception("Error 2")] + mock_excel_instance.parse.side_effect = [TypeError("Error 1"), TypeError("Error 2")] mock_excel_file.return_value = mock_excel_instance file_content = b"fake_excel_all_bad_sheets" @@ -397,6 +398,12 @@ def test_extract_text_from_excel_all_sheets_fail(mock_excel_file): assert mock_excel_instance.parse.call_count == 2 +@patch("pandas.ExcelFile", side_effect=RuntimeError("broken workbook")) +def test_extract_text_from_excel_wraps_workbook_open_errors(mock_excel_file): + with pytest.raises(TextExtractionError, match="Failed to extract text from Excel file: broken workbook"): + _extract_text_from_excel(b"broken") + + @patch("pandas.ExcelFile") def test_extract_text_from_excel_numeric_type_column(mock_excel_file): """Test extracting text from Excel file with numeric column names.""" @@ -420,6 +427,103 @@ def test_extract_text_from_excel_numeric_type_column(mock_excel_file): assert expected_manual == result +@pytest.mark.parametrize( + ("extension", "mime_type"), + [ + (".xlsx", "text/plain"), + (None, "application/vnd.ms-excel"), + ], +) +def test_extract_text_from_file_routes_excel_inputs(document_extractor_node, extension, mime_type): + file = Mock(spec=File) + file.extension = extension + file.mime_type = mime_type + + with ( + patch( + "graphon.nodes.document_extractor.node._download_file_content", + return_value=b"excel", + ), + patch( + "graphon.nodes.document_extractor.node._extract_text_from_excel", + return_value="excel text", + ) as mock_extract, + ): + result = _extract_text_from_file( + document_extractor_node.http_client, + file, + unstructured_api_config=document_extractor_node._unstructured_api_config, + ) + + assert result == "excel text" + mock_extract.assert_called_once_with(b"excel") + + +def test_extract_text_from_file_rejects_missing_extension_and_mime_type(document_extractor_node): + file = Mock(spec=File) + file.extension = None + file.mime_type = None + + with patch( + "graphon.nodes.document_extractor.node._download_file_content", + return_value=b"unknown", + ): + with pytest.raises(UnsupportedFileTypeError, match="Unable to determine file type"): + _extract_text_from_file( + document_extractor_node.http_client, + file, + unstructured_api_config=document_extractor_node._unstructured_api_config, + ) + + +def test_run_list_file_extraction_error_returns_failed(document_extractor_node, mock_graph_runtime_state): + document_extractor_node.graph_runtime_state = mock_graph_runtime_state + file_list = Mock(spec=ArrayFileSegment) + file_list.value = [Mock(spec=File)] + mock_graph_runtime_state.variable_pool.get.return_value = file_list + + with patch( + "graphon.nodes.document_extractor.node._extract_text_from_file", + side_effect=TextExtractionError("bad file"), + ): + result = document_extractor_node._run() + + assert result.status == WorkflowNodeExecutionStatus.FAILED + assert result.error == "bad file" + + +def test_run_single_file_segment_extraction_error_returns_failed(document_extractor_node, mock_graph_runtime_state): + document_extractor_node.graph_runtime_state = mock_graph_runtime_state + file_segment = Mock(spec=FileSegment) + file_segment.value = Mock(spec=File) + mock_graph_runtime_state.variable_pool.get.return_value = file_segment + + with patch( + "graphon.nodes.document_extractor.node._extract_text_from_file", + side_effect=TextExtractionError("single file failed"), + ): + result = document_extractor_node._run() + + assert result.status == WorkflowNodeExecutionStatus.FAILED + assert result.error == "single file failed" + + +def test_run_single_file_segment_returns_string_output(document_extractor_node, mock_graph_runtime_state): + document_extractor_node.graph_runtime_state = mock_graph_runtime_state + file_segment = Mock(spec=FileSegment) + file_segment.value = Mock(spec=File) + mock_graph_runtime_state.variable_pool.get.return_value = file_segment + + with patch( + "graphon.nodes.document_extractor.node._extract_text_from_file", + return_value="single file text", + ): + result = document_extractor_node._run() + + assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED + assert result.outputs == {"text": "single file text"} + + def _make_docx_zip(use_backslash: bool) -> bytes: """Helper to build a minimal in-memory DOCX zip. diff --git a/api/tests/unit_tests/core/workflow/nodes/test_if_else.py b/api/tests/unit_tests/core/workflow/nodes/test_if_else.py index 782750e02e..aa9a1360b0 100644 --- a/api/tests/unit_tests/core/workflow/nodes/test_if_else.py +++ b/api/tests/unit_tests/core/workflow/nodes/test_if_else.py @@ -19,6 +19,20 @@ from graphon.variables import ArrayFileSegment from tests.workflow_test_utils import build_test_graph_init_params +def _build_if_else_node( + *, + node_data: IfElseNodeData | dict[str, object], + init_params, + graph_runtime_state, +) -> IfElseNode: + return IfElseNode( + node_id=str(uuid.uuid4()), + graph_init_params=init_params, + graph_runtime_state=graph_runtime_state, + config=node_data if isinstance(node_data, IfElseNodeData) else IfElseNodeData.model_validate(node_data), + ) + + def test_execute_if_else_result_true(): graph_config = {"edges": [], "nodes": [{"data": {"type": "start", "title": "Start"}, "id": "start"}]} @@ -61,9 +75,8 @@ def test_execute_if_else_result_true(): ) graph = Graph.init(graph_config=graph_config, node_factory=node_factory, root_node_id="start") - node_config = { - "id": "if-else", - "data": { + node = _build_if_else_node( + node_data={ "title": "123", "type": "if-else", "logical_operator": "and", @@ -104,13 +117,8 @@ def test_execute_if_else_result_true(): {"comparison_operator": "not null", "variable_selector": ["start", "not_null"]}, ], }, - } - - node = IfElseNode( - id=str(uuid.uuid4()), - graph_init_params=init_params, + init_params=init_params, graph_runtime_state=graph_runtime_state, - config=node_config, ) # Mock db.session.close() @@ -155,9 +163,8 @@ def test_execute_if_else_result_false(): ) graph = Graph.init(graph_config=graph_config, node_factory=node_factory, root_node_id="start") - node_config = { - "id": "if-else", - "data": { + node = _build_if_else_node( + node_data={ "title": "123", "type": "if-else", "logical_operator": "or", @@ -174,13 +181,8 @@ def test_execute_if_else_result_false(): }, ], }, - } - - node = IfElseNode( - id=str(uuid.uuid4()), - graph_init_params=init_params, + init_params=init_params, graph_runtime_state=graph_runtime_state, - config=node_config, ) # Mock db.session.close() @@ -222,11 +224,6 @@ def test_array_file_contains_file_name(): ], ) - node_config = { - "id": "if-else", - "data": node_data.model_dump(), - } - # Create properly configured mock for graph_init_params graph_init_params = Mock() graph_init_params.workflow_id = "test_workflow" @@ -242,17 +239,12 @@ def test_array_file_contains_file_name(): } } - node = IfElseNode( - id=str(uuid.uuid4()), - graph_init_params=graph_init_params, - graph_runtime_state=Mock(), - config=node_config, - ) + node = _build_if_else_node(node_data=node_data, init_params=graph_init_params, graph_runtime_state=Mock()) node.graph_runtime_state.variable_pool.get.return_value = ArrayFileSegment( value=[ File( - type=FileType.IMAGE, + file_type=FileType.IMAGE, transfer_method=FileTransferMethod.LOCAL_FILE, related_id="1", filename="ab", @@ -334,11 +326,10 @@ def test_execute_if_else_boolean_conditions(condition: Condition): "logical_operator": "and", "conditions": [condition.model_dump()], } - node = IfElseNode( - id=str(uuid.uuid4()), - graph_init_params=init_params, + node = _build_if_else_node( + node_data=node_data, + init_params=init_params, graph_runtime_state=graph_runtime_state, - config={"id": "if-else", "data": node_data}, ) # Mock db.session.close() @@ -400,14 +391,10 @@ def test_execute_if_else_boolean_false_conditions(): ], } - node = IfElseNode( - id=str(uuid.uuid4()), - graph_init_params=init_params, + node = _build_if_else_node( + node_data=node_data, + init_params=init_params, graph_runtime_state=graph_runtime_state, - config={ - "id": "if-else", - "data": node_data, - }, ) # Mock db.session.close() @@ -472,11 +459,10 @@ def test_execute_if_else_boolean_cases_structure(): } ], } - node = IfElseNode( - id=str(uuid.uuid4()), - graph_init_params=init_params, + node = _build_if_else_node( + node_data=node_data, + init_params=init_params, graph_runtime_state=graph_runtime_state, - config={"id": "if-else", "data": node_data}, ) # Mock db.session.close() diff --git a/api/tests/unit_tests/core/workflow/nodes/test_list_operator.py b/api/tests/unit_tests/core/workflow/nodes/test_list_operator.py index b217e4e8e7..465a4c0ff4 100644 --- a/api/tests/unit_tests/core/workflow/nodes/test_list_operator.py +++ b/api/tests/unit_tests/core/workflow/nodes/test_list_operator.py @@ -19,6 +19,15 @@ from graphon.nodes.list_operator.node import ListOperatorNode, _get_file_extract from graphon.variables import ArrayFileSegment +def _build_list_operator_node(node_data: ListOperatorNodeData, graph_init_params) -> ListOperatorNode: + return ListOperatorNode( + node_id="test_node_id", + config=node_data, + graph_init_params=graph_init_params, + graph_runtime_state=MagicMock(), + ) + + @pytest.fixture def list_operator_node(): config = { @@ -35,10 +44,6 @@ def list_operator_node(): "title": "Test Title", } node_data = ListOperatorNodeData.model_validate(config) - node_config = { - "id": "test_node_id", - "data": node_data.model_dump(), - } # Create properly configured mock for graph_init_params graph_init_params = MagicMock() graph_init_params.workflow_id = "test_workflow" @@ -54,12 +59,7 @@ def list_operator_node(): } } - node = ListOperatorNode( - id="test_node_id", - config=node_config, - graph_init_params=graph_init_params, - graph_runtime_state=MagicMock(), - ) + node = _build_list_operator_node(node_data, graph_init_params) node.graph_runtime_state = MagicMock() node.graph_runtime_state.variable_pool = MagicMock() return node @@ -70,28 +70,28 @@ def test_filter_files_by_type(list_operator_node): files = [ File( filename="image1.jpg", - type=FileType.IMAGE, + file_type=FileType.IMAGE, transfer_method=FileTransferMethod.LOCAL_FILE, related_id="related1", storage_key="", ), File( filename="document1.pdf", - type=FileType.DOCUMENT, + file_type=FileType.DOCUMENT, transfer_method=FileTransferMethod.LOCAL_FILE, related_id="related2", storage_key="", ), File( filename="image2.png", - type=FileType.IMAGE, + file_type=FileType.IMAGE, transfer_method=FileTransferMethod.LOCAL_FILE, related_id="related3", storage_key="", ), File( filename="audio1.mp3", - type=FileType.AUDIO, + file_type=FileType.AUDIO, transfer_method=FileTransferMethod.LOCAL_FILE, related_id="related4", storage_key="", @@ -136,7 +136,7 @@ def test_filter_files_by_type(list_operator_node): def test_get_file_extract_string_func(): # Create a File object file = File( - type=FileType.DOCUMENT, + file_type=FileType.DOCUMENT, transfer_method=FileTransferMethod.LOCAL_FILE, filename="test_file.txt", extension=".txt", @@ -156,7 +156,7 @@ def test_get_file_extract_string_func(): # Test with empty values empty_file = File( - type=FileType.DOCUMENT, + file_type=FileType.DOCUMENT, transfer_method=FileTransferMethod.LOCAL_FILE, filename=None, extension=None, diff --git a/api/tests/unit_tests/core/workflow/nodes/test_start_node_json_object.py b/api/tests/unit_tests/core/workflow/nodes/test_start_node_json_object.py index 543f9878de..5655f80737 100644 --- a/api/tests/unit_tests/core/workflow/nodes/test_start_node_json_object.py +++ b/api/tests/unit_tests/core/workflow/nodes/test_start_node_json_object.py @@ -22,10 +22,7 @@ def make_start_node(user_inputs, variables): inputs=user_inputs, ) - config = { - "id": "start", - "data": StartNodeData(title="Start", variables=variables).model_dump(), - } + node_data = StartNodeData(title="Start", variables=variables) graph_runtime_state = GraphRuntimeState( variable_pool=variable_pool, @@ -33,8 +30,8 @@ def make_start_node(user_inputs, variables): ) return StartNode( - id="start", - config=config, + node_id="start", + config=node_data, graph_init_params=build_test_graph_init_params( workflow_id="wf", graph_config={}, @@ -109,7 +106,7 @@ def test_json_object_invalid_json_string(): node = make_start_node(user_inputs, variables) - with pytest.raises(ValueError, match="JSON object for 'profile' must be an object"): + with pytest.raises(TypeError, match="JSON object for 'profile' must be an object"): node._run() @@ -248,25 +245,22 @@ def test_start_node_outputs_full_variable_pool_snapshot(): inputs={"profile": {"age": 20, "name": "Tom"}}, ) - config = { - "id": "start", - "data": StartNodeData( - title="Start", - variables=[ - VariableEntity( - variable="profile", - label="profile", - type=VariableEntityType.JSON_OBJECT, - required=True, - ) - ], - ).model_dump(), - } + node_data = StartNodeData( + title="Start", + variables=[ + VariableEntity( + variable="profile", + label="profile", + type=VariableEntityType.JSON_OBJECT, + required=True, + ) + ], + ) graph_runtime_state = GraphRuntimeState(variable_pool=variable_pool, start_at=time.perf_counter()) node = StartNode( - id="start", - config=config, + node_id="start", + config=node_data, graph_init_params=build_test_graph_init_params( workflow_id="wf", graph_config={}, diff --git a/api/tests/unit_tests/core/workflow/nodes/tool/test_tool_node.py b/api/tests/unit_tests/core/workflow/nodes/tool/test_tool_node.py index c806181340..284af68319 100644 --- a/api/tests/unit_tests/core/workflow/nodes/tool/test_tool_node.py +++ b/api/tests/unit_tests/core/workflow/nodes/tool/test_tool_node.py @@ -13,6 +13,7 @@ from core.workflow.system_variables import build_system_variables from graphon.file import File, FileTransferMethod, FileType from graphon.model_runtime.entities.llm_entities import LLMUsage from graphon.node_events import StreamChunkEvent, StreamCompletedEvent +from graphon.nodes.tool.entities import ToolNodeData from graphon.nodes.tool_runtime_entities import ToolRuntimeHandle, ToolRuntimeMessage from graphon.runtime import GraphRuntimeState, VariablePool from graphon.variables.segments import ArrayFileSegment @@ -108,8 +109,8 @@ def tool_node(monkeypatch) -> ToolNode: runtime = _StubToolRuntime() node = ToolNode( - id="node-instance", - config=config, + node_id="node-instance", + config=ToolNodeData.model_validate(config["data"]), graph_init_params=init_params, graph_runtime_state=graph_runtime_state, tool_file_manager_factory=tool_file_manager_factory, @@ -118,13 +119,13 @@ def tool_node(monkeypatch) -> ToolNode: return node -def _collect_events(generator: Generator) -> tuple[list[Any], LLMUsage]: +def _collect_events(generator: Generator) -> list[Any]: events: list[Any] = [] try: while True: events.append(next(generator)) - except StopIteration as stop: - return events, stop.value + except StopIteration: + return events def _run_transform(tool_node: ToolNode, message: ToolRuntimeMessage) -> tuple[list[Any], LLMUsage]: @@ -135,12 +136,15 @@ def _run_transform(tool_node: ToolNode, message: ToolRuntimeMessage) -> tuple[li node_id=tool_node._node_id, tool_runtime=ToolRuntimeHandle(raw=object()), ) - return _collect_events(generator) + events = _collect_events(generator) + completed_events = [event for event in events if isinstance(event, StreamCompletedEvent)] + assert completed_events + return events, completed_events[-1].node_run_result.llm_usage def test_link_messages_with_file_populate_files_output(tool_node: ToolNode): file_obj = File( - type=FileType.DOCUMENT, + file_type=FileType.DOCUMENT, transfer_method=FileTransferMethod.TOOL_FILE, related_id="file-id", filename="demo.pdf", @@ -195,7 +199,7 @@ def test_plain_link_messages_remain_links(tool_node: ToolNode): def test_image_link_messages_use_tool_file_id_metadata(tool_node: ToolNode): file_obj = File( - type=FileType.DOCUMENT, + file_type=FileType.DOCUMENT, transfer_method=FileTransferMethod.TOOL_FILE, related_id="file-id", filename="demo.pdf", diff --git a/api/tests/unit_tests/core/workflow/nodes/trigger_plugin/test_trigger_event_node.py b/api/tests/unit_tests/core/workflow/nodes/trigger_plugin/test_trigger_event_node.py index c8ddc53284..e3b5e3b591 100644 --- a/api/tests/unit_tests/core/workflow/nodes/trigger_plugin/test_trigger_event_node.py +++ b/api/tests/unit_tests/core/workflow/nodes/trigger_plugin/test_trigger_event_node.py @@ -1,10 +1,10 @@ from collections.abc import Mapping from core.trigger.constants import TRIGGER_PLUGIN_NODE_TYPE +from core.workflow.nodes.trigger_plugin.entities import TriggerEventNodeData from core.workflow.nodes.trigger_plugin.trigger_event_node import TriggerEventNode from core.workflow.system_variables import build_system_variables from graphon.entities import GraphInitParams -from graphon.entities.graph_config import NodeConfigDict, NodeConfigDictAdapter from graphon.enums import WorkflowNodeExecutionMetadataKey, WorkflowNodeExecutionStatus from graphon.runtime import GraphRuntimeState from tests.workflow_test_utils import build_test_graph_init_params, build_test_variable_pool @@ -27,29 +27,24 @@ def _build_context(graph_config: Mapping[str, object]) -> tuple[GraphInitParams, return init_params, runtime_state -def _build_node_config() -> NodeConfigDict: - return NodeConfigDictAdapter.validate_python( - { - "id": "node-1", - "data": { - "type": TRIGGER_PLUGIN_NODE_TYPE, - "title": "Trigger Event", - "plugin_id": "plugin-id", - "provider_id": "provider-id", - "event_name": "event-name", - "subscription_id": "subscription-id", - "plugin_unique_identifier": "plugin-unique-identifier", - "event_parameters": {}, - }, - } +def _build_node_data() -> TriggerEventNodeData: + return TriggerEventNodeData( + type=TRIGGER_PLUGIN_NODE_TYPE, + title="Trigger Event", + plugin_id="plugin-id", + provider_id="provider-id", + event_name="event-name", + subscription_id="subscription-id", + plugin_unique_identifier="plugin-unique-identifier", + event_parameters={}, ) def test_trigger_event_node_run_populates_trigger_info_metadata() -> None: init_params, runtime_state = _build_context(graph_config={}) node = TriggerEventNode( - id="node-1", - config=_build_node_config(), + node_id="node-1", + config=_build_node_data(), graph_init_params=init_params, graph_runtime_state=runtime_state, ) diff --git a/api/tests/unit_tests/core/workflow/nodes/webhook/test_webhook_file_conversion.py b/api/tests/unit_tests/core/workflow/nodes/webhook/test_webhook_file_conversion.py index 1bbc12b23f..07d03bec05 100644 --- a/api/tests/unit_tests/core/workflow/nodes/webhook/test_webhook_file_conversion.py +++ b/api/tests/unit_tests/core/workflow/nodes/webhook/test_webhook_file_conversion.py @@ -30,11 +30,6 @@ def create_webhook_node( tenant_id: str = "test-tenant", ) -> TriggerWebhookNode: """Helper function to create a webhook node with proper initialization.""" - node_config = { - "id": "webhook-node-1", - "data": webhook_data.model_dump(), - } - graph_init_params = GraphInitParams( workflow_id="test-workflow", graph_config={}, @@ -56,8 +51,8 @@ def create_webhook_node( ) node = TriggerWebhookNode( - id="webhook-node-1", - config=node_config, + node_id="webhook-node-1", + config=webhook_data, graph_init_params=graph_init_params, graph_runtime_state=runtime_state, ) @@ -66,10 +61,6 @@ def create_webhook_node( runtime_state.app_config = Mock() runtime_state.app_config.tenant_id = tenant_id - # Provide compatibility alias expected by node implementation - # Some nodes reference `self.node_id`; expose it as an alias to `self.id` for tests - node.node_id = node.id - return node diff --git a/api/tests/unit_tests/core/workflow/nodes/webhook/test_webhook_node.py b/api/tests/unit_tests/core/workflow/nodes/webhook/test_webhook_node.py index 427afa96ec..b839490d3c 100644 --- a/api/tests/unit_tests/core/workflow/nodes/webhook/test_webhook_node.py +++ b/api/tests/unit_tests/core/workflow/nodes/webhook/test_webhook_node.py @@ -24,11 +24,6 @@ from tests.workflow_test_utils import build_test_variable_pool def create_webhook_node(webhook_data: WebhookData, variable_pool: VariablePool) -> TriggerWebhookNode: """Helper function to create a webhook node with proper initialization.""" - node_config = { - "id": "1", - "data": webhook_data.model_dump(), - } - graph_init_params = GraphInitParams( workflow_id="1", graph_config={}, @@ -48,8 +43,8 @@ def create_webhook_node(webhook_data: WebhookData, variable_pool: VariablePool) start_at=0, ) node = TriggerWebhookNode( - id="1", - config=node_config, + node_id="1", + config=webhook_data, graph_init_params=graph_init_params, graph_runtime_state=runtime_state, ) @@ -57,9 +52,6 @@ def create_webhook_node(webhook_data: WebhookData, variable_pool: VariablePool) # Provide tenant_id for conversion path runtime_state.app_config = type("_AppCfg", (), {"tenant_id": "1"})() - # Compatibility alias for some nodes referencing `self.node_id` - node.node_id = node.id - return node @@ -225,7 +217,7 @@ def test_webhook_node_run_with_file_params(): """Test webhook node execution with file parameter extraction.""" # Create mock file objects file1 = File( - type=FileType.IMAGE, + file_type=FileType.IMAGE, transfer_method=FileTransferMethod.LOCAL_FILE, related_id="file1", filename="image.jpg", @@ -234,7 +226,7 @@ def test_webhook_node_run_with_file_params(): ) file2 = File( - type=FileType.DOCUMENT, + file_type=FileType.DOCUMENT, transfer_method=FileTransferMethod.LOCAL_FILE, related_id="file2", filename="document.pdf", @@ -269,8 +261,19 @@ def test_webhook_node_run_with_file_params(): # Mock the node's file reference boundary to avoid DB-dependent validation on upload_file_id with patch.object(node._file_reference_factory, "build_from_mapping") as mock_file_factory: - def _to_file(*, mapping): - return File.model_validate(mapping) + def _to_file(*, mapping: dict[str, Any]) -> File: + return File( + file_id=mapping.get("id"), + file_type=FileType(mapping["type"]), + transfer_method=FileTransferMethod(mapping["transfer_method"]), + related_id=mapping.get("related_id"), + filename=mapping.get("filename"), + extension=mapping.get("extension"), + mime_type=mapping.get("mime_type"), + size=mapping.get("size", -1), + storage_key=mapping.get("storage_key", ""), + remote_url=mapping.get("url"), + ) mock_file_factory.side_effect = _to_file result = node._run() @@ -284,7 +287,7 @@ def test_webhook_node_run_with_file_params(): def test_webhook_node_run_mixed_parameters(): """Test webhook node execution with mixed parameter types.""" file_obj = File( - type=FileType.IMAGE, + file_type=FileType.IMAGE, transfer_method=FileTransferMethod.LOCAL_FILE, related_id="file1", filename="test.jpg", @@ -317,8 +320,19 @@ def test_webhook_node_run_mixed_parameters(): # Mock the node's file reference boundary to avoid DB-dependent validation on upload_file_id with patch.object(node._file_reference_factory, "build_from_mapping") as mock_file_factory: - def _to_file(*, mapping): - return File.model_validate(mapping) + def _to_file(*, mapping: dict[str, Any]) -> File: + return File( + file_id=mapping.get("id"), + file_type=FileType(mapping["type"]), + transfer_method=FileTransferMethod(mapping["transfer_method"]), + related_id=mapping.get("related_id"), + filename=mapping.get("filename"), + extension=mapping.get("extension"), + mime_type=mapping.get("mime_type"), + size=mapping.get("size", -1), + storage_key=mapping.get("storage_key", ""), + remote_url=mapping.get("url"), + ) mock_file_factory.side_effect = _to_file result = node._run() diff --git a/api/tests/unit_tests/core/workflow/test_human_input_adapter.py b/api/tests/unit_tests/core/workflow/test_human_input_adapter.py new file mode 100644 index 0000000000..8b5fceeb37 --- /dev/null +++ b/api/tests/unit_tests/core/workflow/test_human_input_adapter.py @@ -0,0 +1,350 @@ +from types import SimpleNamespace + +import pytest +from pydantic import BaseModel + +from core.workflow.human_input_adapter import ( + DeliveryMethodType, + EmailDeliveryConfig, + EmailDeliveryMethod, + EmailRecipients, + WebAppDeliveryMethod, + _WebAppDeliveryConfig, + adapt_human_input_node_data_for_graph, + adapt_node_config_for_graph, + adapt_node_data_for_graph, + is_human_input_webapp_enabled, + parse_human_input_delivery_methods, +) +from graphon.enums import BuiltinNodeTypes +from graphon.nodes.base.variable_template_parser import VariableTemplateParser + + +def test_email_delivery_config_helpers_render_and_sanitize_text() -> None: + variable_pool = SimpleNamespace( + convert_template=lambda body: SimpleNamespace(text=body.replace("{{#node.value#}}", "42")) + ) + + rendered = EmailDeliveryConfig.render_body_template( + body="Open {{#url#}} and use {{#node.value#}}", + url="https://example.com", + variable_pool=variable_pool, + ) + sanitized = EmailDeliveryConfig.sanitize_subject("Hello\r\n Team") + html = EmailDeliveryConfig.render_markdown_body( + "**Hello** [mail](mailto:test@example.com)" + ) + + assert rendered == "Open https://example.com and use 42" + assert sanitized == "Hello alert(1) Team" + assert "Hello" in html + assert " Team") - html = EmailDeliveryConfig.render_markdown_body( - "**Hello** [mail](mailto:test@example.com)" - ) - - assert rendered == "Open https://example.com and use 42" - assert sanitized == "Hello alert(1) Team" - assert "Hello" in html - assert "