From dcde854c5e5dbad1347424b8a959825d1300e63b Mon Sep 17 00:00:00 2001 From: Joel Date: Wed, 24 Dec 2025 14:45:33 +0800 Subject: [PATCH 01/11] chore: some tests (#30078) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../app/log-annotation/index.spec.tsx | 176 ++++++++++++ web/app/components/base/input/index.tsx | 6 +- .../billing/priority-label/index.spec.tsx | 125 ++++++++ .../explore/app-list/index.spec.tsx | 271 ++++++++++++++++++ web/app/components/explore/category.spec.tsx | 79 +++++ web/app/components/explore/index.spec.tsx | 140 +++++++++ .../explore/item-operation/index.spec.tsx | 109 +++++++ .../explore/item-operation/index.tsx | 6 +- .../sidebar/app-nav-item/index.spec.tsx | 99 +++++++ .../components/explore/sidebar/index.spec.tsx | 164 +++++++++++ 10 files changed, 1173 insertions(+), 2 deletions(-) create mode 100644 web/app/components/app/log-annotation/index.spec.tsx create mode 100644 web/app/components/billing/priority-label/index.spec.tsx create mode 100644 web/app/components/explore/app-list/index.spec.tsx create mode 100644 web/app/components/explore/category.spec.tsx create mode 100644 web/app/components/explore/index.spec.tsx create mode 100644 web/app/components/explore/item-operation/index.spec.tsx create mode 100644 web/app/components/explore/sidebar/app-nav-item/index.spec.tsx create mode 100644 web/app/components/explore/sidebar/index.spec.tsx diff --git a/web/app/components/app/log-annotation/index.spec.tsx b/web/app/components/app/log-annotation/index.spec.tsx new file mode 100644 index 0000000000..064092f20e --- /dev/null +++ b/web/app/components/app/log-annotation/index.spec.tsx @@ -0,0 +1,176 @@ +import type { App, AppIconType } from '@/types/app' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { useStore as useAppStore } from '@/app/components/app/store' +import { PageType } from '@/app/components/base/features/new-feature-panel/annotation-reply/type' +import { AppModeEnum } from '@/types/app' +import LogAnnotation from './index' + +const mockRouterPush = vi.fn() +vi.mock('next/navigation', () => ({ + useRouter: () => ({ + push: mockRouterPush, + }), +})) + +vi.mock('@/app/components/app/annotation', () => ({ + __esModule: true, + default: ({ appDetail }: { appDetail: App }) => ( +
+ ), +})) + +vi.mock('@/app/components/app/log', () => ({ + __esModule: true, + default: ({ appDetail }: { appDetail: App }) => ( +
+ ), +})) + +vi.mock('@/app/components/app/workflow-log', () => ({ + __esModule: true, + default: ({ appDetail }: { appDetail: App }) => ( +
+ ), +})) + +const createMockApp = (overrides: Partial = {}): App => ({ + id: 'app-123', + name: 'Test App', + description: 'Test app description', + author_name: 'Test Author', + icon_type: 'emoji' as AppIconType, + icon: ':icon:', + icon_background: '#FFEAD5', + icon_url: null, + use_icon_as_answer_icon: false, + mode: AppModeEnum.CHAT, + enable_site: true, + enable_api: true, + api_rpm: 60, + api_rph: 3600, + is_demo: false, + model_config: {} as App['model_config'], + app_model_config: {} as App['app_model_config'], + created_at: Date.now(), + updated_at: Date.now(), + site: { + access_token: 'token', + app_base_url: 'https://example.com', + } as App['site'], + api_base_url: 'https://api.example.com', + tags: [], + access_mode: 'public_access' as App['access_mode'], + ...overrides, +}) + +describe('LogAnnotation', () => { + beforeEach(() => { + vi.clearAllMocks() + useAppStore.setState({ appDetail: createMockApp() }) + }) + + // Rendering behavior + describe('Rendering', () => { + it('should render loading state when app detail is missing', () => { + // Arrange + useAppStore.setState({ appDetail: undefined }) + + // Act + render() + + // Assert + expect(screen.getByRole('status')).toBeInTheDocument() + }) + + it('should render log and annotation tabs for non-completion apps', () => { + // Arrange + useAppStore.setState({ appDetail: createMockApp({ mode: AppModeEnum.CHAT }) }) + + // Act + render() + + // Assert + expect(screen.getByText('appLog.title')).toBeInTheDocument() + expect(screen.getByText('appAnnotation.title')).toBeInTheDocument() + }) + + it('should render only log tab for completion apps', () => { + // Arrange + useAppStore.setState({ appDetail: createMockApp({ mode: AppModeEnum.COMPLETION }) }) + + // Act + render() + + // Assert + expect(screen.getByText('appLog.title')).toBeInTheDocument() + expect(screen.queryByText('appAnnotation.title')).not.toBeInTheDocument() + }) + + it('should hide tabs and render workflow log in workflow mode', () => { + // Arrange + useAppStore.setState({ appDetail: createMockApp({ mode: AppModeEnum.WORKFLOW }) }) + + // Act + render() + + // Assert + expect(screen.queryByText('appLog.title')).not.toBeInTheDocument() + expect(screen.getByTestId('workflow-log')).toBeInTheDocument() + }) + }) + + // Prop-driven behavior + describe('Props', () => { + it('should render log content when page type is log', () => { + // Arrange + useAppStore.setState({ appDetail: createMockApp({ mode: AppModeEnum.CHAT }) }) + + // Act + render() + + // Assert + expect(screen.getByTestId('log')).toBeInTheDocument() + expect(screen.queryByTestId('annotation')).not.toBeInTheDocument() + }) + + it('should render annotation content when page type is annotation', () => { + // Arrange + useAppStore.setState({ appDetail: createMockApp({ mode: AppModeEnum.CHAT }) }) + + // Act + render() + + // Assert + expect(screen.getByTestId('annotation')).toBeInTheDocument() + expect(screen.queryByTestId('log')).not.toBeInTheDocument() + }) + }) + + // User interaction behavior + describe('User Interactions', () => { + it('should navigate to annotations when switching from log tab', async () => { + // Arrange + const user = userEvent.setup() + + // Act + render() + await user.click(screen.getByText('appAnnotation.title')) + + // Assert + expect(mockRouterPush).toHaveBeenCalledWith('/app/app-123/annotations') + }) + + it('should navigate to logs when switching from annotation tab', async () => { + // Arrange + const user = userEvent.setup() + + // Act + render() + await user.click(screen.getByText('appLog.title')) + + // Assert + expect(mockRouterPush).toHaveBeenCalledWith('/app/app-123/logs') + }) + }) +}) diff --git a/web/app/components/base/input/index.tsx b/web/app/components/base/input/index.tsx index 98529a26bc..6c6a0c6a75 100644 --- a/web/app/components/base/input/index.tsx +++ b/web/app/components/base/input/index.tsx @@ -110,7 +110,11 @@ const Input = React.forwardRef(({ {...props} /> {showClearIcon && value && !disabled && !destructive && ( -
+
)} diff --git a/web/app/components/billing/priority-label/index.spec.tsx b/web/app/components/billing/priority-label/index.spec.tsx new file mode 100644 index 0000000000..0d176d1611 --- /dev/null +++ b/web/app/components/billing/priority-label/index.spec.tsx @@ -0,0 +1,125 @@ +import type { Mock } from 'vitest' +import { fireEvent, render, screen } from '@testing-library/react' +import { createMockPlan } from '@/__mocks__/provider-context' +import { useProviderContext } from '@/context/provider-context' +import { Plan } from '../type' +import PriorityLabel from './index' + +vi.mock('@/context/provider-context', () => ({ + useProviderContext: vi.fn(), +})) + +const useProviderContextMock = useProviderContext as Mock + +const setupPlan = (planType: Plan) => { + useProviderContextMock.mockReturnValue(createMockPlan(planType)) +} + +describe('PriorityLabel', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // Rendering: basic label output for sandbox plan. + describe('Rendering', () => { + it('should render the standard priority label when plan is sandbox', () => { + // Arrange + setupPlan(Plan.sandbox) + + // Act + render() + + // Assert + expect(screen.getByText('billing.plansCommon.priority.standard')).toBeInTheDocument() + }) + }) + + // Props: custom class name applied to the label container. + describe('Props', () => { + it('should apply custom className to the label container', () => { + // Arrange + setupPlan(Plan.sandbox) + + // Act + render() + + // Assert + const label = screen.getByText('billing.plansCommon.priority.standard').closest('div') + expect(label).toHaveClass('custom-class') + }) + }) + + // Plan types: label text and icon visibility for different plans. + describe('Plan Types', () => { + it('should render priority label and icon when plan is professional', () => { + // Arrange + setupPlan(Plan.professional) + + // Act + const { container } = render() + + // Assert + expect(screen.getByText('billing.plansCommon.priority.priority')).toBeInTheDocument() + expect(container.querySelector('svg')).toBeInTheDocument() + }) + + it('should render top priority label and icon when plan is team', () => { + // Arrange + setupPlan(Plan.team) + + // Act + const { container } = render() + + // Assert + expect(screen.getByText('billing.plansCommon.priority.top-priority')).toBeInTheDocument() + expect(container.querySelector('svg')).toBeInTheDocument() + }) + + it('should render standard label without icon when plan is sandbox', () => { + // Arrange + setupPlan(Plan.sandbox) + + // Act + const { container } = render() + + // Assert + expect(screen.getByText('billing.plansCommon.priority.standard')).toBeInTheDocument() + expect(container.querySelector('svg')).not.toBeInTheDocument() + }) + }) + + // Edge cases: tooltip content varies by priority level. + describe('Edge Cases', () => { + it('should show the tip text when priority is not top priority', async () => { + // Arrange + setupPlan(Plan.sandbox) + + // Act + render() + const label = screen.getByText('billing.plansCommon.priority.standard').closest('div') + fireEvent.mouseEnter(label as HTMLElement) + + // Assert + expect(await screen.findByText( + 'billing.plansCommon.documentProcessingPriority: billing.plansCommon.priority.standard', + )).toBeInTheDocument() + expect(screen.getByText('billing.plansCommon.documentProcessingPriorityTip')).toBeInTheDocument() + }) + + it('should hide the tip text when priority is top priority', async () => { + // Arrange + setupPlan(Plan.enterprise) + + // Act + render() + const label = screen.getByText('billing.plansCommon.priority.top-priority').closest('div') + fireEvent.mouseEnter(label as HTMLElement) + + // Assert + expect(await screen.findByText( + 'billing.plansCommon.documentProcessingPriority: billing.plansCommon.priority.top-priority', + )).toBeInTheDocument() + expect(screen.queryByText('billing.plansCommon.documentProcessingPriorityTip')).not.toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/explore/app-list/index.spec.tsx b/web/app/components/explore/app-list/index.spec.tsx new file mode 100644 index 0000000000..e73fcdf0ad --- /dev/null +++ b/web/app/components/explore/app-list/index.spec.tsx @@ -0,0 +1,271 @@ +import type { Mock } from 'vitest' +import type { CreateAppModalProps } from '@/app/components/explore/create-app-modal' +import type { App } from '@/models/explore' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import ExploreContext from '@/context/explore-context' +import { fetchAppDetail } from '@/service/explore' +import { AppModeEnum } from '@/types/app' +import AppList from './index' + +const allCategoriesEn = 'explore.apps.allCategories:{"lng":"en"}' +let mockTabValue = allCategoriesEn +const mockSetTab = vi.fn() +let mockSWRData: { categories: string[], allList: App[] } = { categories: [], allList: [] } +const mockHandleImportDSL = vi.fn() +const mockHandleImportDSLConfirm = vi.fn() + +vi.mock('@/hooks/use-tab-searchparams', () => ({ + useTabSearchParams: () => [mockTabValue, mockSetTab], +})) + +vi.mock('ahooks', async () => { + const actual = await vi.importActual('ahooks') + const React = await vi.importActual('react') + return { + ...actual, + useDebounceFn: (fn: (...args: unknown[]) => void) => { + const fnRef = React.useRef(fn) + fnRef.current = fn + return { + run: () => setTimeout(() => fnRef.current(), 0), + } + }, + } +}) + +vi.mock('swr', () => ({ + __esModule: true, + default: () => ({ data: mockSWRData }), +})) + +vi.mock('@/service/explore', () => ({ + fetchAppDetail: vi.fn(), + fetchAppList: vi.fn(), +})) + +vi.mock('@/hooks/use-import-dsl', () => ({ + useImportDSL: () => ({ + handleImportDSL: mockHandleImportDSL, + handleImportDSLConfirm: mockHandleImportDSLConfirm, + versions: ['v1'], + isFetching: false, + }), +})) + +vi.mock('@/app/components/explore/create-app-modal', () => ({ + __esModule: true, + default: (props: CreateAppModalProps) => { + if (!props.show) + return null + return ( +
+ + +
+ ) + }, +})) + +vi.mock('@/app/components/app/create-from-dsl-modal/dsl-confirm-modal', () => ({ + __esModule: true, + default: ({ onConfirm, onCancel }: { onConfirm: () => void, onCancel: () => void }) => ( +
+ + +
+ ), +})) + +const createApp = (overrides: Partial = {}): App => ({ + app: { + id: overrides.app?.id ?? 'app-basic-id', + mode: overrides.app?.mode ?? AppModeEnum.CHAT, + icon_type: overrides.app?.icon_type ?? 'emoji', + icon: overrides.app?.icon ?? '😀', + icon_background: overrides.app?.icon_background ?? '#fff', + icon_url: overrides.app?.icon_url ?? '', + name: overrides.app?.name ?? 'Alpha', + description: overrides.app?.description ?? 'Alpha description', + use_icon_as_answer_icon: overrides.app?.use_icon_as_answer_icon ?? false, + }, + app_id: overrides.app_id ?? 'app-1', + description: overrides.description ?? 'Alpha description', + copyright: overrides.copyright ?? '', + privacy_policy: overrides.privacy_policy ?? null, + custom_disclaimer: overrides.custom_disclaimer ?? null, + category: overrides.category ?? 'Writing', + position: overrides.position ?? 1, + is_listed: overrides.is_listed ?? true, + install_count: overrides.install_count ?? 0, + installed: overrides.installed ?? false, + editable: overrides.editable ?? false, + is_agent: overrides.is_agent ?? false, +}) + +const renderWithContext = (hasEditPermission = false, onSuccess?: () => void) => { + return render( + + + , + ) +} + +describe('AppList', () => { + beforeEach(() => { + vi.clearAllMocks() + mockTabValue = allCategoriesEn + mockSWRData = { categories: [], allList: [] } + }) + + // Rendering: show loading when categories are not ready. + describe('Rendering', () => { + it('should render loading when categories are empty', () => { + // Arrange + mockSWRData = { categories: [], allList: [] } + + // Act + renderWithContext() + + // Assert + expect(screen.getByRole('status')).toBeInTheDocument() + }) + + it('should render app cards when data is available', () => { + // Arrange + mockSWRData = { + categories: ['Writing', 'Translate'], + allList: [createApp(), createApp({ app_id: 'app-2', app: { ...createApp().app, name: 'Beta' }, category: 'Translate' })], + } + + // Act + renderWithContext() + + // Assert + expect(screen.getByText('Alpha')).toBeInTheDocument() + expect(screen.getByText('Beta')).toBeInTheDocument() + }) + }) + + // Props: category selection filters the list. + describe('Props', () => { + it('should filter apps by selected category', () => { + // Arrange + mockTabValue = 'Writing' + mockSWRData = { + categories: ['Writing', 'Translate'], + allList: [createApp(), createApp({ app_id: 'app-2', app: { ...createApp().app, name: 'Beta' }, category: 'Translate' })], + } + + // Act + renderWithContext() + + // Assert + expect(screen.getByText('Alpha')).toBeInTheDocument() + expect(screen.queryByText('Beta')).not.toBeInTheDocument() + }) + }) + + // User interactions: search and create flow. + describe('User Interactions', () => { + it('should filter apps by search keywords', async () => { + // Arrange + mockSWRData = { + categories: ['Writing'], + allList: [createApp(), createApp({ app_id: 'app-2', app: { ...createApp().app, name: 'Gamma' } })], + } + renderWithContext() + + // Act + const input = screen.getByPlaceholderText('common.operation.search') + fireEvent.change(input, { target: { value: 'gam' } }) + + // Assert + await waitFor(() => { + expect(screen.queryByText('Alpha')).not.toBeInTheDocument() + expect(screen.getByText('Gamma')).toBeInTheDocument() + }) + }) + + it('should handle create flow and confirm DSL when pending', async () => { + // Arrange + const onSuccess = vi.fn() + mockSWRData = { + categories: ['Writing'], + allList: [createApp()], + }; + (fetchAppDetail as unknown as Mock).mockResolvedValue({ export_data: 'yaml-content' }) + mockHandleImportDSL.mockImplementation(async (_payload: unknown, options: { onSuccess?: () => void, onPending?: () => void }) => { + options.onPending?.() + }) + mockHandleImportDSLConfirm.mockImplementation(async (options: { onSuccess?: () => void }) => { + options.onSuccess?.() + }) + + // Act + renderWithContext(true, onSuccess) + fireEvent.click(screen.getByText('explore.appCard.addToWorkspace')) + fireEvent.click(await screen.findByTestId('confirm-create')) + + // Assert + await waitFor(() => { + expect(fetchAppDetail).toHaveBeenCalledWith('app-basic-id') + }) + expect(mockHandleImportDSL).toHaveBeenCalledTimes(1) + expect(await screen.findByTestId('dsl-confirm-modal')).toBeInTheDocument() + + fireEvent.click(screen.getByTestId('dsl-confirm')) + await waitFor(() => { + expect(mockHandleImportDSLConfirm).toHaveBeenCalledTimes(1) + expect(onSuccess).toHaveBeenCalledTimes(1) + }) + }) + }) + + // Edge cases: handle clearing search keywords. + describe('Edge Cases', () => { + it('should reset search results when clear icon is clicked', async () => { + // Arrange + mockSWRData = { + categories: ['Writing'], + allList: [createApp(), createApp({ app_id: 'app-2', app: { ...createApp().app, name: 'Gamma' } })], + } + renderWithContext() + + // Act + const input = screen.getByPlaceholderText('common.operation.search') + fireEvent.change(input, { target: { value: 'gam' } }) + await waitFor(() => { + expect(screen.queryByText('Alpha')).not.toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('input-clear')) + + // Assert + await waitFor(() => { + expect(screen.getByText('Alpha')).toBeInTheDocument() + expect(screen.getByText('Gamma')).toBeInTheDocument() + }) + }) + }) +}) diff --git a/web/app/components/explore/category.spec.tsx b/web/app/components/explore/category.spec.tsx new file mode 100644 index 0000000000..a84b17c844 --- /dev/null +++ b/web/app/components/explore/category.spec.tsx @@ -0,0 +1,79 @@ +import type { AppCategory } from '@/models/explore' +import { fireEvent, render, screen } from '@testing-library/react' +import Category from './category' + +describe('Category', () => { + const allCategoriesEn = 'Recommended' + + const renderComponent = (overrides: Partial> = {}) => { + const props: React.ComponentProps = { + list: ['Writing', 'Recommended'] as AppCategory[], + value: allCategoriesEn, + onChange: vi.fn(), + allCategoriesEn, + ...overrides, + } + return { + props, + ...render(), + } + } + + // Rendering: basic categories and all-categories button. + describe('Rendering', () => { + it('should render all categories item and translated categories', () => { + // Arrange + renderComponent() + + // Assert + expect(screen.getByText('explore.apps.allCategories')).toBeInTheDocument() + expect(screen.getByText('explore.category.Writing')).toBeInTheDocument() + }) + + it('should not render allCategoriesEn again inside the category list', () => { + // Arrange + renderComponent() + + // Assert + const recommendedItems = screen.getAllByText('explore.apps.allCategories') + expect(recommendedItems).toHaveLength(1) + }) + }) + + // Props: clicking items triggers onChange. + describe('Props', () => { + it('should call onChange with category value when category item is clicked', () => { + // Arrange + const { props } = renderComponent() + + // Act + fireEvent.click(screen.getByText('explore.category.Writing')) + + // Assert + expect(props.onChange).toHaveBeenCalledWith('Writing') + }) + + it('should call onChange with allCategoriesEn when all categories is clicked', () => { + // Arrange + const { props } = renderComponent({ value: 'Writing' }) + + // Act + fireEvent.click(screen.getByText('explore.apps.allCategories')) + + // Assert + expect(props.onChange).toHaveBeenCalledWith(allCategoriesEn) + }) + }) + + // Edge cases: handle values not in the list. + describe('Edge Cases', () => { + it('should treat unknown value as all categories selection', () => { + // Arrange + renderComponent({ value: 'Unknown' }) + + // Assert + const allCategoriesItem = screen.getByText('explore.apps.allCategories') + expect(allCategoriesItem.className).toContain('bg-components-main-nav-nav-button-bg-active') + }) + }) +}) diff --git a/web/app/components/explore/index.spec.tsx b/web/app/components/explore/index.spec.tsx new file mode 100644 index 0000000000..8f361ad471 --- /dev/null +++ b/web/app/components/explore/index.spec.tsx @@ -0,0 +1,140 @@ +import type { Mock } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import { useContext } from 'use-context-selector' +import { useAppContext } from '@/context/app-context' +import ExploreContext from '@/context/explore-context' +import { MediaType } from '@/hooks/use-breakpoints' +import useDocumentTitle from '@/hooks/use-document-title' +import { useMembers } from '@/service/use-common' +import Explore from './index' + +const mockReplace = vi.fn() +const mockPush = vi.fn() +const mockInstalledAppsData = { installed_apps: [] as const } + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ + replace: mockReplace, + push: mockPush, + }), + useSelectedLayoutSegments: () => ['apps'], +})) + +vi.mock('@/hooks/use-breakpoints', () => ({ + __esModule: true, + default: () => MediaType.pc, + MediaType: { + mobile: 'mobile', + tablet: 'tablet', + pc: 'pc', + }, +})) + +vi.mock('@/service/use-explore', () => ({ + useGetInstalledApps: () => ({ + isFetching: false, + data: mockInstalledAppsData, + refetch: vi.fn(), + }), + useUninstallApp: () => ({ + mutateAsync: vi.fn(), + }), + useUpdateAppPinStatus: () => ({ + mutateAsync: vi.fn(), + }), +})) + +vi.mock('@/context/app-context', () => ({ + useAppContext: vi.fn(), +})) + +vi.mock('@/service/use-common', () => ({ + useMembers: vi.fn(), +})) + +vi.mock('@/hooks/use-document-title', () => ({ + __esModule: true, + default: vi.fn(), +})) + +const ContextReader = () => { + const { hasEditPermission } = useContext(ExploreContext) + return
{hasEditPermission ? 'edit-yes' : 'edit-no'}
+} + +describe('Explore', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // Rendering: provides ExploreContext and children. + describe('Rendering', () => { + it('should render children and provide edit permission from members role', async () => { + // Arrange + ; (useAppContext as Mock).mockReturnValue({ + userProfile: { id: 'user-1' }, + isCurrentWorkspaceDatasetOperator: false, + }); + (useMembers as Mock).mockReturnValue({ + data: { + accounts: [{ id: 'user-1', role: 'admin' }], + }, + }) + + // Act + render(( + + + + )) + + // Assert + await waitFor(() => { + expect(screen.getByText('edit-yes')).toBeInTheDocument() + }) + }) + }) + + // Effects: set document title and redirect dataset operators. + describe('Effects', () => { + it('should set document title on render', () => { + // Arrange + ; (useAppContext as Mock).mockReturnValue({ + userProfile: { id: 'user-1' }, + isCurrentWorkspaceDatasetOperator: false, + }); + (useMembers as Mock).mockReturnValue({ data: { accounts: [] } }) + + // Act + render(( + +
child
+
+ )) + + // Assert + expect(useDocumentTitle).toHaveBeenCalledWith('common.menus.explore') + }) + + it('should redirect dataset operators to /datasets', async () => { + // Arrange + ; (useAppContext as Mock).mockReturnValue({ + userProfile: { id: 'user-1' }, + isCurrentWorkspaceDatasetOperator: true, + }); + (useMembers as Mock).mockReturnValue({ data: { accounts: [] } }) + + // Act + render(( + +
child
+
+ )) + + // Assert + await waitFor(() => { + expect(mockReplace).toHaveBeenCalledWith('/datasets') + }) + }) + }) +}) diff --git a/web/app/components/explore/item-operation/index.spec.tsx b/web/app/components/explore/item-operation/index.spec.tsx new file mode 100644 index 0000000000..9084e5564e --- /dev/null +++ b/web/app/components/explore/item-operation/index.spec.tsx @@ -0,0 +1,109 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import ItemOperation from './index' + +describe('ItemOperation', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + const renderComponent = (overrides: Partial> = {}) => { + const props: React.ComponentProps = { + isPinned: false, + isShowDelete: true, + togglePin: vi.fn(), + onDelete: vi.fn(), + ...overrides, + } + return { + props, + ...render(), + } + } + + // Rendering: menu items show after opening. + describe('Rendering', () => { + it('should render pin and delete actions when menu is open', async () => { + // Arrange + renderComponent() + + // Act + fireEvent.click(screen.getByTestId('item-operation-trigger')) + + // Assert + expect(await screen.findByText('explore.sidebar.action.pin')).toBeInTheDocument() + expect(screen.getByText('explore.sidebar.action.delete')).toBeInTheDocument() + }) + }) + + // Props: render optional rename action and pinned label text. + describe('Props', () => { + it('should render rename action when isShowRenameConversation is true', async () => { + // Arrange + renderComponent({ isShowRenameConversation: true }) + + // Act + fireEvent.click(screen.getByTestId('item-operation-trigger')) + + // Assert + expect(await screen.findByText('explore.sidebar.action.rename')).toBeInTheDocument() + }) + + it('should render unpin label when isPinned is true', async () => { + // Arrange + renderComponent({ isPinned: true }) + + // Act + fireEvent.click(screen.getByTestId('item-operation-trigger')) + + // Assert + expect(await screen.findByText('explore.sidebar.action.unpin')).toBeInTheDocument() + }) + }) + + // User interactions: clicking action items triggers callbacks. + describe('User Interactions', () => { + it('should call togglePin when clicking pin action', async () => { + // Arrange + const { props } = renderComponent() + + // Act + fireEvent.click(screen.getByTestId('item-operation-trigger')) + fireEvent.click(await screen.findByText('explore.sidebar.action.pin')) + + // Assert + expect(props.togglePin).toHaveBeenCalledTimes(1) + }) + + it('should call onDelete when clicking delete action', async () => { + // Arrange + const { props } = renderComponent() + + // Act + fireEvent.click(screen.getByTestId('item-operation-trigger')) + fireEvent.click(await screen.findByText('explore.sidebar.action.delete')) + + // Assert + expect(props.onDelete).toHaveBeenCalledTimes(1) + }) + }) + + // Edge cases: menu closes after mouse leave when no hovering state remains. + describe('Edge Cases', () => { + it('should close the menu when mouse leaves the panel and item is not hovering', async () => { + // Arrange + 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 + + // Act + fireEvent.mouseEnter(menu) + fireEvent.mouseLeave(menu) + + // Assert + await waitFor(() => { + expect(screen.queryByText('explore.sidebar.action.pin')).not.toBeInTheDocument() + }) + }) + }) +}) diff --git a/web/app/components/explore/item-operation/index.tsx b/web/app/components/explore/item-operation/index.tsx index 3703c0d4c0..abbfdd09bd 100644 --- a/web/app/components/explore/item-operation/index.tsx +++ b/web/app/components/explore/item-operation/index.tsx @@ -53,7 +53,11 @@ const ItemOperation: FC = ({ setOpen(v => !v)} > -
+
+
({ + useRouter: () => ({ + push: mockPush, + }), +})) + +vi.mock('ahooks', async () => { + const actual = await vi.importActual('ahooks') + return { + ...actual, + useHover: () => false, + } +}) + +const baseProps = { + isMobile: false, + name: 'My App', + id: 'app-123', + icon_type: 'emoji' as const, + icon: '🤖', + icon_background: '#fff', + icon_url: '', + isSelected: false, + isPinned: false, + togglePin: vi.fn(), + uninstallable: false, + onDelete: vi.fn(), +} + +describe('AppNavItem', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // Rendering: display app name for desktop and hide for mobile. + describe('Rendering', () => { + it('should render name and item operation on desktop', () => { + // Arrange + render() + + // Assert + expect(screen.getByText('My App')).toBeInTheDocument() + expect(screen.getByTestId('item-operation-trigger')).toBeInTheDocument() + }) + + it('should hide name on mobile', () => { + // Arrange + render() + + // Assert + expect(screen.queryByText('My App')).not.toBeInTheDocument() + }) + }) + + // User interactions: navigation and delete flow. + describe('User Interactions', () => { + it('should navigate to installed app when item is clicked', () => { + // Arrange + render() + + // Act + fireEvent.click(screen.getByText('My App')) + + // Assert + expect(mockPush).toHaveBeenCalledWith('/explore/installed/app-123') + }) + + it('should call onDelete with app id when delete action is clicked', async () => { + // Arrange + render() + + // Act + fireEvent.click(screen.getByTestId('item-operation-trigger')) + fireEvent.click(await screen.findByText('explore.sidebar.action.delete')) + + // Assert + expect(baseProps.onDelete).toHaveBeenCalledWith('app-123') + }) + }) + + // Edge cases: hide delete when uninstallable or selected. + describe('Edge Cases', () => { + it('should not render delete action when app is uninstallable', () => { + // Arrange + render() + + // Act + fireEvent.click(screen.getByTestId('item-operation-trigger')) + + // Assert + expect(screen.queryByText('explore.sidebar.action.delete')).not.toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/explore/sidebar/index.spec.tsx b/web/app/components/explore/sidebar/index.spec.tsx new file mode 100644 index 0000000000..0cbd05aa08 --- /dev/null +++ b/web/app/components/explore/sidebar/index.spec.tsx @@ -0,0 +1,164 @@ +import type { InstalledApp } from '@/models/explore' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import Toast from '@/app/components/base/toast' +import ExploreContext from '@/context/explore-context' +import { MediaType } from '@/hooks/use-breakpoints' +import { AppModeEnum } from '@/types/app' +import SideBar from './index' + +const mockSegments = ['apps'] +const mockPush = vi.fn() +const mockRefetch = vi.fn() +const mockUninstall = vi.fn() +const mockUpdatePinStatus = vi.fn() +let mockIsFetching = false +let mockInstalledApps: InstalledApp[] = [] + +vi.mock('next/navigation', () => ({ + useSelectedLayoutSegments: () => mockSegments, + useRouter: () => ({ + push: mockPush, + }), +})) + +vi.mock('@/hooks/use-breakpoints', () => ({ + __esModule: true, + default: () => MediaType.pc, + MediaType: { + mobile: 'mobile', + tablet: 'tablet', + pc: 'pc', + }, +})) + +vi.mock('@/service/use-explore', () => ({ + useGetInstalledApps: () => ({ + isFetching: mockIsFetching, + data: { installed_apps: mockInstalledApps }, + refetch: mockRefetch, + }), + useUninstallApp: () => ({ + mutateAsync: mockUninstall, + }), + useUpdateAppPinStatus: () => ({ + mutateAsync: mockUpdatePinStatus, + }), +})) + +const createInstalledApp = (overrides: Partial = {}): InstalledApp => ({ + id: overrides.id ?? 'app-123', + uninstallable: overrides.uninstallable ?? false, + is_pinned: overrides.is_pinned ?? false, + app: { + id: overrides.app?.id ?? 'app-basic-id', + mode: overrides.app?.mode ?? AppModeEnum.CHAT, + icon_type: overrides.app?.icon_type ?? 'emoji', + icon: overrides.app?.icon ?? '🤖', + icon_background: overrides.app?.icon_background ?? '#fff', + icon_url: overrides.app?.icon_url ?? '', + name: overrides.app?.name ?? 'My App', + description: overrides.app?.description ?? 'desc', + use_icon_as_answer_icon: overrides.app?.use_icon_as_answer_icon ?? false, + }, +}) + +const renderWithContext = (installedApps: InstalledApp[] = []) => { + return render( + + + , + ) +} + +describe('SideBar', () => { + beforeEach(() => { + vi.clearAllMocks() + mockIsFetching = false + mockInstalledApps = [] + vi.spyOn(Toast, 'notify').mockImplementation(() => ({ clear: vi.fn() })) + }) + + // Rendering: show discovery and workspace section. + describe('Rendering', () => { + it('should render workspace items when installed apps exist', () => { + // Arrange + mockInstalledApps = [createInstalledApp()] + + // Act + renderWithContext(mockInstalledApps) + + // Assert + expect(screen.getByText('explore.sidebar.discovery')).toBeInTheDocument() + expect(screen.getByText('explore.sidebar.workspace')).toBeInTheDocument() + expect(screen.getByText('My App')).toBeInTheDocument() + }) + }) + + // Effects: refresh and sync installed apps state. + describe('Effects', () => { + it('should refetch installed apps on mount', () => { + // Arrange + mockInstalledApps = [createInstalledApp()] + + // Act + renderWithContext(mockInstalledApps) + + // Assert + expect(mockRefetch).toHaveBeenCalledTimes(1) + }) + }) + + // User interactions: delete and pin flows. + describe('User Interactions', () => { + it('should uninstall app and show toast when delete is confirmed', async () => { + // Arrange + mockInstalledApps = [createInstalledApp()] + mockUninstall.mockResolvedValue(undefined) + renderWithContext(mockInstalledApps) + + // Act + fireEvent.click(screen.getByTestId('item-operation-trigger')) + fireEvent.click(await screen.findByText('explore.sidebar.action.delete')) + fireEvent.click(await screen.findByText('common.operation.confirm')) + + // Assert + await waitFor(() => { + expect(mockUninstall).toHaveBeenCalledWith('app-123') + expect(Toast.notify).toHaveBeenCalledWith(expect.objectContaining({ + type: 'success', + message: 'common.api.remove', + })) + }) + }) + + it('should update pin status and show toast when pin is clicked', async () => { + // Arrange + mockInstalledApps = [createInstalledApp({ is_pinned: false })] + mockUpdatePinStatus.mockResolvedValue(undefined) + renderWithContext(mockInstalledApps) + + // Act + fireEvent.click(screen.getByTestId('item-operation-trigger')) + fireEvent.click(await screen.findByText('explore.sidebar.action.pin')) + + // Assert + await waitFor(() => { + expect(mockUpdatePinStatus).toHaveBeenCalledWith({ appId: 'app-123', isPinned: true }) + expect(Toast.notify).toHaveBeenCalledWith(expect.objectContaining({ + type: 'success', + message: 'common.api.success', + })) + }) + }) + }) +}) From b2b7e82e281512a7249aabc754dbe4d65f825ad9 Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Wed, 24 Dec 2025 15:25:28 +0800 Subject: [PATCH 02/11] refactor(web): migrate log service to TanStack Query (#30065) --- .claude/skills/frontend-testing/SKILL.md | 6 +- .../assets/component-test.template.tsx | 2 +- .../frontend-testing/references/checklist.md | 14 +- .../frontend-testing/references/mocking.md | 25 +- .../frontend-testing/references/workflow.md | 12 +- .github/workflows/web-tests.yml | 2 +- .../components/app/annotation/filter.spec.tsx | 350 +++++++++++-- web/app/components/app/annotation/filter.tsx | 8 +- web/app/components/app/log/filter.tsx | 7 +- web/app/components/app/log/index.tsx | 23 +- web/app/components/app/log/list.tsx | 10 +- .../app/workflow-log/index.spec.tsx | 494 ++++++++++-------- web/app/components/app/workflow-log/index.tsx | 9 +- web/app/components/base/skeleton/index.tsx | 3 +- .../model-parameter-modal/model-display.tsx | 30 +- web/models/log.ts | 15 - web/package.json | 1 + web/service/log.ts | 54 +- web/service/use-log.ts | 89 ++++ web/testing/testing.md | 4 +- 20 files changed, 741 insertions(+), 417 deletions(-) create mode 100644 web/service/use-log.ts diff --git a/.claude/skills/frontend-testing/SKILL.md b/.claude/skills/frontend-testing/SKILL.md index 7475513ba0..65602c92eb 100644 --- a/.claude/skills/frontend-testing/SKILL.md +++ b/.claude/skills/frontend-testing/SKILL.md @@ -49,10 +49,10 @@ pnpm test pnpm test:watch # Run specific file -pnpm test -- path/to/file.spec.tsx +pnpm test path/to/file.spec.tsx # Generate coverage report -pnpm test -- --coverage +pnpm test:coverage # Analyze component complexity pnpm analyze-component @@ -155,7 +155,7 @@ describe('ComponentName', () => { For each file: ┌────────────────────────────────────────┐ │ 1. Write test │ - │ 2. Run: pnpm test -- .spec.tsx │ + │ 2. Run: pnpm test .spec.tsx │ │ 3. PASS? → Mark complete, next file │ │ FAIL? → Fix first, then continue │ └────────────────────────────────────────┘ diff --git a/.claude/skills/frontend-testing/assets/component-test.template.tsx b/.claude/skills/frontend-testing/assets/component-test.template.tsx index 92dd797c83..c39baff916 100644 --- a/.claude/skills/frontend-testing/assets/component-test.template.tsx +++ b/.claude/skills/frontend-testing/assets/component-test.template.tsx @@ -198,7 +198,7 @@ describe('ComponentName', () => { }) // -------------------------------------------------------------------------- - // Async Operations (if component fetches data - useSWR, useQuery, fetch) + // Async Operations (if component fetches data - useQuery, fetch) // -------------------------------------------------------------------------- // WHY: Async operations have 3 states users experience: loading, success, error describe('Async Operations', () => { diff --git a/.claude/skills/frontend-testing/references/checklist.md b/.claude/skills/frontend-testing/references/checklist.md index aad80b120e..1ff2b27bbb 100644 --- a/.claude/skills/frontend-testing/references/checklist.md +++ b/.claude/skills/frontend-testing/references/checklist.md @@ -114,15 +114,15 @@ For the current file being tested: **Run these checks after EACH test file, not just at the end:** -- [ ] Run `pnpm test -- path/to/file.spec.tsx` - **MUST PASS before next file** +- [ ] Run `pnpm test path/to/file.spec.tsx` - **MUST PASS before next file** - [ ] Fix any failures immediately - [ ] Mark file as complete in todo list - [ ] Only then proceed to next file ### After All Files Complete -- [ ] Run full directory test: `pnpm test -- path/to/directory/` -- [ ] Check coverage report: `pnpm test -- --coverage` +- [ ] Run full directory test: `pnpm test path/to/directory/` +- [ ] Check coverage report: `pnpm test:coverage` - [ ] Run `pnpm lint:fix` on all test files - [ ] Run `pnpm type-check:tsgo` @@ -186,16 +186,16 @@ Always test these scenarios: ```bash # Run specific test -pnpm test -- path/to/file.spec.tsx +pnpm test path/to/file.spec.tsx # Run with coverage -pnpm test -- --coverage path/to/file.spec.tsx +pnpm test:coverage path/to/file.spec.tsx # Watch mode -pnpm test:watch -- path/to/file.spec.tsx +pnpm test:watch path/to/file.spec.tsx # Update snapshots (use sparingly) -pnpm test -- -u path/to/file.spec.tsx +pnpm test -u path/to/file.spec.tsx # Analyze component pnpm analyze-component path/to/component.tsx diff --git a/.claude/skills/frontend-testing/references/mocking.md b/.claude/skills/frontend-testing/references/mocking.md index 51920ebc64..23889c8d3d 100644 --- a/.claude/skills/frontend-testing/references/mocking.md +++ b/.claude/skills/frontend-testing/references/mocking.md @@ -242,32 +242,9 @@ describe('Component with Context', () => { }) ``` -### 7. SWR / React Query +### 7. React Query ```typescript -// SWR -vi.mock('swr', () => ({ - __esModule: true, - default: vi.fn(), -})) - -import useSWR from 'swr' -const mockedUseSWR = vi.mocked(useSWR) - -describe('Component with SWR', () => { - it('should show loading state', () => { - mockedUseSWR.mockReturnValue({ - data: undefined, - error: undefined, - isLoading: true, - }) - - render() - expect(screen.getByText(/loading/i)).toBeInTheDocument() - }) -}) - -// React Query import { QueryClient, QueryClientProvider } from '@tanstack/react-query' const createTestQueryClient = () => new QueryClient({ diff --git a/.claude/skills/frontend-testing/references/workflow.md b/.claude/skills/frontend-testing/references/workflow.md index b0f2994bde..009c3e013b 100644 --- a/.claude/skills/frontend-testing/references/workflow.md +++ b/.claude/skills/frontend-testing/references/workflow.md @@ -35,7 +35,7 @@ When testing a **single component, hook, or utility**: 2. Run `pnpm analyze-component ` (if available) 3. Check complexity score and features detected 4. Write the test file -5. Run test: `pnpm test -- .spec.tsx` +5. Run test: `pnpm test .spec.tsx` 6. Fix any failures 7. Verify coverage meets goals (100% function, >95% branch) ``` @@ -80,7 +80,7 @@ Process files in this recommended order: ``` ┌─────────────────────────────────────────────┐ │ 1. Write test file │ -│ 2. Run: pnpm test -- .spec.tsx │ +│ 2. Run: pnpm test .spec.tsx │ │ 3. If FAIL → Fix immediately, re-run │ │ 4. If PASS → Mark complete in todo list │ │ 5. ONLY THEN proceed to next file │ @@ -95,10 +95,10 @@ After all individual tests pass: ```bash # Run all tests in the directory together -pnpm test -- path/to/directory/ +pnpm test path/to/directory/ # Check coverage -pnpm test -- --coverage path/to/directory/ +pnpm test:coverage path/to/directory/ ``` ## Component Complexity Guidelines @@ -201,9 +201,9 @@ Run pnpm test ← Multiple failures, hard to debug ``` # GOOD: Incremental with verification Write component-a.spec.tsx -Run pnpm test -- component-a.spec.tsx ✅ +Run pnpm test component-a.spec.tsx ✅ Write component-b.spec.tsx -Run pnpm test -- component-b.spec.tsx ✅ +Run pnpm test component-b.spec.tsx ✅ ...continue... ``` diff --git a/.github/workflows/web-tests.yml b/.github/workflows/web-tests.yml index 8eba0f084b..adf52a1362 100644 --- a/.github/workflows/web-tests.yml +++ b/.github/workflows/web-tests.yml @@ -42,7 +42,7 @@ jobs: run: pnpm run check:i18n-types - name: Run tests - run: pnpm test --coverage + run: pnpm test:coverage - name: Coverage Summary if: always() diff --git a/web/app/components/app/annotation/filter.spec.tsx b/web/app/components/app/annotation/filter.spec.tsx index 9b733a8c10..7bb39bd444 100644 --- a/web/app/components/app/annotation/filter.spec.tsx +++ b/web/app/components/app/annotation/filter.spec.tsx @@ -1,72 +1,332 @@ +import type { UseQueryResult } from '@tanstack/react-query' import type { Mock } from 'vitest' import type { QueryParam } from './filter' +import type { AnnotationsCountResponse } from '@/models/log' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { fireEvent, render, screen } from '@testing-library/react' import * as React from 'react' -import useSWR from 'swr' +import * as useLogModule from '@/service/use-log' import Filter from './filter' -vi.mock('swr', () => ({ - __esModule: true, - default: vi.fn(), -})) +vi.mock('@/service/use-log') -vi.mock('@/service/log', () => ({ - fetchAnnotationsCount: vi.fn(), -})) +const mockUseAnnotationsCount = useLogModule.useAnnotationsCount as Mock -const mockUseSWR = useSWR as unknown as Mock +// ============================================================================ +// Test Utilities +// ============================================================================ + +const createQueryClient = () => new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, +}) + +const renderWithQueryClient = (ui: React.ReactElement) => { + const queryClient = createQueryClient() + return render( + + {ui} + , + ) +} + +// ============================================================================ +// Mock Return Value Factory +// ============================================================================ + +type MockQueryResult = Pick, 'data' | 'isLoading' | 'error' | 'refetch'> + +const createMockQueryResult = ( + overrides: Partial> = {}, +): MockQueryResult => ({ + data: undefined, + isLoading: false, + error: null, + refetch: vi.fn(), + ...overrides, +}) + +// ============================================================================ +// Tests +// ============================================================================ describe('Filter', () => { const appId = 'app-1' const childContent = 'child-content' + const defaultQueryParams: QueryParam = { keyword: '' } beforeEach(() => { vi.clearAllMocks() }) - it('should render nothing until annotation count is fetched', () => { - mockUseSWR.mockReturnValue({ data: undefined }) + // -------------------------------------------------------------------------- + // Rendering Tests (REQUIRED) + // -------------------------------------------------------------------------- + describe('Rendering', () => { + it('should render nothing when data is loading', () => { + // Arrange + mockUseAnnotationsCount.mockReturnValue( + createMockQueryResult({ isLoading: true }), + ) - const { container } = render( - -
{childContent}
-
, - ) + // Act + const { container } = renderWithQueryClient( + +
{childContent}
+
, + ) - expect(container.firstChild).toBeNull() - expect(mockUseSWR).toHaveBeenCalledWith( - { url: `/apps/${appId}/annotations/count` }, - expect.any(Function), - ) + // Assert + expect(container.firstChild).toBeNull() + }) + + it('should render nothing when data is undefined', () => { + // Arrange + mockUseAnnotationsCount.mockReturnValue( + createMockQueryResult({ data: undefined, isLoading: false }), + ) + + // Act + const { container } = renderWithQueryClient( + +
{childContent}
+
, + ) + + // Assert + expect(container.firstChild).toBeNull() + }) + + it('should render filter and children when data is available', () => { + // Arrange + mockUseAnnotationsCount.mockReturnValue( + createMockQueryResult({ + data: { count: 20 }, + isLoading: false, + }), + ) + + // Act + renderWithQueryClient( + +
{childContent}
+
, + ) + + // Assert + expect(screen.getByPlaceholderText('common.operation.search')).toBeInTheDocument() + expect(screen.getByText(childContent)).toBeInTheDocument() + }) }) - it('should propagate keyword changes and clearing behavior', () => { - mockUseSWR.mockReturnValue({ data: { total: 20 } }) - const queryParams: QueryParam = { keyword: 'prefill' } - const setQueryParams = vi.fn() + // -------------------------------------------------------------------------- + // Props Tests (REQUIRED) + // -------------------------------------------------------------------------- + describe('Props', () => { + it('should call useAnnotationsCount with appId', () => { + // Arrange + mockUseAnnotationsCount.mockReturnValue( + createMockQueryResult({ + data: { count: 10 }, + isLoading: false, + }), + ) - const { container } = render( - -
{childContent}
-
, - ) + // Act + renderWithQueryClient( + +
{childContent}
+
, + ) - const input = screen.getByPlaceholderText('common.operation.search') as HTMLInputElement - fireEvent.change(input, { target: { value: 'updated' } }) - expect(setQueryParams).toHaveBeenCalledWith({ ...queryParams, keyword: 'updated' }) + // Assert + expect(mockUseAnnotationsCount).toHaveBeenCalledWith(appId) + }) - const clearButton = input.parentElement?.querySelector('div.cursor-pointer') as HTMLElement - fireEvent.click(clearButton) - expect(setQueryParams).toHaveBeenCalledWith({ ...queryParams, keyword: '' }) + it('should display keyword value in input', () => { + // Arrange + mockUseAnnotationsCount.mockReturnValue( + createMockQueryResult({ + data: { count: 10 }, + isLoading: false, + }), + ) + const queryParams: QueryParam = { keyword: 'test-keyword' } - expect(container).toHaveTextContent(childContent) + // Act + renderWithQueryClient( + +
{childContent}
+
, + ) + + // Assert + expect(screen.getByPlaceholderText('common.operation.search')).toHaveValue('test-keyword') + }) + }) + + // -------------------------------------------------------------------------- + // User Interactions + // -------------------------------------------------------------------------- + describe('User Interactions', () => { + it('should call setQueryParams when typing in search input', () => { + // Arrange + mockUseAnnotationsCount.mockReturnValue( + createMockQueryResult({ + data: { count: 20 }, + isLoading: false, + }), + ) + const queryParams: QueryParam = { keyword: '' } + const setQueryParams = vi.fn() + + renderWithQueryClient( + +
{childContent}
+
, + ) + + // Act + const input = screen.getByPlaceholderText('common.operation.search') + fireEvent.change(input, { target: { value: 'updated' } }) + + // Assert + expect(setQueryParams).toHaveBeenCalledWith({ ...queryParams, keyword: 'updated' }) + }) + + it('should call setQueryParams with empty keyword when clearing input', () => { + // Arrange + mockUseAnnotationsCount.mockReturnValue( + createMockQueryResult({ + data: { count: 20 }, + isLoading: false, + }), + ) + const queryParams: QueryParam = { keyword: 'prefill' } + const setQueryParams = vi.fn() + + renderWithQueryClient( + +
{childContent}
+
, + ) + + // Act + const input = screen.getByPlaceholderText('common.operation.search') + const clearButton = input.parentElement?.querySelector('div.cursor-pointer') + if (clearButton) + fireEvent.click(clearButton) + + // Assert + expect(setQueryParams).toHaveBeenCalledWith({ ...queryParams, keyword: '' }) + }) + }) + + // -------------------------------------------------------------------------- + // Edge Cases (REQUIRED) + // -------------------------------------------------------------------------- + describe('Edge Cases', () => { + it('should handle empty keyword in queryParams', () => { + // Arrange + mockUseAnnotationsCount.mockReturnValue( + createMockQueryResult({ + data: { count: 5 }, + isLoading: false, + }), + ) + + // Act + renderWithQueryClient( + +
{childContent}
+
, + ) + + // Assert + expect(screen.getByPlaceholderText('common.operation.search')).toHaveValue('') + }) + + it('should handle undefined keyword in queryParams', () => { + // Arrange + mockUseAnnotationsCount.mockReturnValue( + createMockQueryResult({ + data: { count: 5 }, + isLoading: false, + }), + ) + + // Act + renderWithQueryClient( + +
{childContent}
+
, + ) + + // Assert + expect(screen.getByPlaceholderText('common.operation.search')).toBeInTheDocument() + }) + + it('should handle zero count', () => { + // Arrange + mockUseAnnotationsCount.mockReturnValue( + createMockQueryResult({ + data: { count: 0 }, + isLoading: false, + }), + ) + + // Act + renderWithQueryClient( + +
{childContent}
+
, + ) + + // Assert - should still render when count is 0 + expect(screen.getByPlaceholderText('common.operation.search')).toBeInTheDocument() + }) }) }) diff --git a/web/app/components/app/annotation/filter.tsx b/web/app/components/app/annotation/filter.tsx index 76f33d2f1b..b64a033793 100644 --- a/web/app/components/app/annotation/filter.tsx +++ b/web/app/components/app/annotation/filter.tsx @@ -2,9 +2,8 @@ import type { FC } from 'react' import * as React from 'react' import { useTranslation } from 'react-i18next' -import useSWR from 'swr' import Input from '@/app/components/base/input' -import { fetchAnnotationsCount } from '@/service/log' +import { useAnnotationsCount } from '@/service/use-log' export type QueryParam = { keyword?: string @@ -23,10 +22,9 @@ const Filter: FC = ({ setQueryParams, children, }) => { - // TODO: change fetch list api - const { data } = useSWR({ url: `/apps/${appId}/annotations/count` }, fetchAnnotationsCount) + const { data, isLoading } = useAnnotationsCount(appId) const { t } = useTranslation() - if (!data) + if (isLoading || !data) return null return (
diff --git a/web/app/components/app/log/filter.tsx b/web/app/components/app/log/filter.tsx index 8984ff3494..4a0103449f 100644 --- a/web/app/components/app/log/filter.tsx +++ b/web/app/components/app/log/filter.tsx @@ -6,11 +6,10 @@ import dayjs from 'dayjs' import quarterOfYear from 'dayjs/plugin/quarterOfYear' import * as React from 'react' import { useTranslation } from 'react-i18next' -import useSWR from 'swr' import Chip from '@/app/components/base/chip' import Input from '@/app/components/base/input' import Sort from '@/app/components/base/sort' -import { fetchAnnotationsCount } from '@/service/log' +import { useAnnotationsCount } from '@/service/use-log' dayjs.extend(quarterOfYear) @@ -36,9 +35,9 @@ type IFilterProps = { } const Filter: FC = ({ isChatMode, appId, queryParams, setQueryParams }: IFilterProps) => { - const { data } = useSWR({ url: `/apps/${appId}/annotations/count` }, fetchAnnotationsCount) + const { data, isLoading } = useAnnotationsCount(appId) const { t } = useTranslation() - if (!data) + if (isLoading || !data) return null return (
diff --git a/web/app/components/app/log/index.tsx b/web/app/components/app/log/index.tsx index 183826464f..0e6e4bba2d 100644 --- a/web/app/components/app/log/index.tsx +++ b/web/app/components/app/log/index.tsx @@ -8,11 +8,10 @@ import { usePathname, useRouter, useSearchParams } from 'next/navigation' import * as React from 'react' import { useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' -import useSWR from 'swr' import Loading from '@/app/components/base/loading' import Pagination from '@/app/components/base/pagination' import { APP_PAGE_LIMIT } from '@/config' -import { fetchChatConversations, fetchCompletionConversations } from '@/service/log' +import { useChatConversations, useCompletionConversations } from '@/service/use-log' import { AppModeEnum } from '@/types/app' import EmptyElement from './empty-element' import Filter, { TIME_PERIOD_MAPPING } from './filter' @@ -88,19 +87,15 @@ const Logs: FC = ({ appDetail }) => { } // When the details are obtained, proceed to the next request - const { data: chatConversations, mutate: mutateChatList } = useSWR(() => isChatMode - ? { - url: `/apps/${appDetail.id}/chat-conversations`, - params: query, - } - : null, fetchChatConversations) + const { data: chatConversations, refetch: mutateChatList } = useChatConversations({ + appId: isChatMode ? appDetail.id : '', + params: query, + }) - const { data: completionConversations, mutate: mutateCompletionList } = useSWR(() => !isChatMode - ? { - url: `/apps/${appDetail.id}/completion-conversations`, - params: query, - } - : null, fetchCompletionConversations) + const { data: completionConversations, refetch: mutateCompletionList } = useCompletionConversations({ + appId: !isChatMode ? appDetail.id : '', + params: query, + }) const total = isChatMode ? chatConversations?.total : completionConversations?.total diff --git a/web/app/components/app/log/list.tsx b/web/app/components/app/log/list.tsx index 06cd20b323..2b13a09b3a 100644 --- a/web/app/components/app/log/list.tsx +++ b/web/app/components/app/log/list.tsx @@ -17,7 +17,6 @@ import { usePathname, useRouter, useSearchParams } from 'next/navigation' import * as React from 'react' import { useCallback, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import useSWR from 'swr' import { createContext, useContext } from 'use-context-selector' import { useShallow } from 'zustand/react/shallow' import ModelInfo from '@/app/components/app/log/model-info' @@ -38,7 +37,8 @@ import { WorkflowContextProvider } from '@/app/components/workflow/context' import { useAppContext } from '@/context/app-context' import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' import useTimestamp from '@/hooks/use-timestamp' -import { fetchChatConversationDetail, fetchChatMessages, fetchCompletionConversationDetail, updateLogMessageAnnotations, updateLogMessageFeedbacks } from '@/service/log' +import { fetchChatMessages, updateLogMessageAnnotations, updateLogMessageFeedbacks } from '@/service/log' +import { useChatConversationDetail, useCompletionConversationDetail } from '@/service/use-log' import { AppModeEnum } from '@/types/app' import { cn } from '@/utils/classnames' import PromptLogModal from '../../base/prompt-log-modal' @@ -825,8 +825,7 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) { */ const CompletionConversationDetailComp: FC<{ appId?: string, conversationId?: string }> = ({ appId, conversationId }) => { // Text Generator App Session Details Including Message List - const detailParams = ({ url: `/apps/${appId}/completion-conversations/${conversationId}` }) - const { data: conversationDetail, mutate: conversationDetailMutate } = useSWR(() => (appId && conversationId) ? detailParams : null, fetchCompletionConversationDetail) + const { data: conversationDetail, refetch: conversationDetailMutate } = useCompletionConversationDetail(appId, conversationId) const { notify } = useContext(ToastContext) const { t } = useTranslation() @@ -875,8 +874,7 @@ const CompletionConversationDetailComp: FC<{ appId?: string, conversationId?: st * Chat App Conversation Detail Component */ const ChatConversationDetailComp: FC<{ appId?: string, conversationId?: string }> = ({ appId, conversationId }) => { - const detailParams = { url: `/apps/${appId}/chat-conversations/${conversationId}` } - const { data: conversationDetail } = useSWR(() => (appId && conversationId) ? detailParams : null, fetchChatConversationDetail) + const { data: conversationDetail } = useChatConversationDetail(appId, conversationId) const { notify } = useContext(ToastContext) const { t } = useTranslation() diff --git a/web/app/components/app/workflow-log/index.spec.tsx b/web/app/components/app/workflow-log/index.spec.tsx index 8d87a6426b..d689758b30 100644 --- a/web/app/components/app/workflow-log/index.spec.tsx +++ b/web/app/components/app/workflow-log/index.spec.tsx @@ -1,9 +1,9 @@ -import type { MockedFunction } from 'vitest' +import type { UseQueryResult } from '@tanstack/react-query' /** * Logs Container Component Tests * * Tests the main Logs container component which: - * - Fetches workflow logs via useSWR + * - Fetches workflow logs via TanStack Query * - Manages query parameters (status, period, keyword) * - Handles pagination * - Renders Filter, List, and Empty states @@ -15,14 +15,16 @@ import type { MockedFunction } from 'vitest' * - trigger-by-display.spec.tsx */ +import type { MockedFunction } from 'vitest' import type { ILogsProps } from './index' import type { WorkflowAppLogDetail, WorkflowLogsResponse, WorkflowRunDetail } from '@/models/log' import type { App, AppIconType, AppModeEnum } from '@/types/app' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import useSWR from 'swr' import { APP_PAGE_LIMIT } from '@/config' import { WorkflowRunTriggeredFrom } from '@/models/log' +import * as useLogModule from '@/service/use-log' import { TIME_PERIOD_MAPPING } from './filter' import Logs from './index' @@ -30,7 +32,7 @@ import Logs from './index' // Mocks // ============================================================================ -vi.mock('swr') +vi.mock('@/service/use-log') vi.mock('ahooks', () => ({ useDebounce: (value: T) => value, @@ -72,10 +74,6 @@ vi.mock('@/app/components/base/amplitude/utils', () => ({ trackEvent: (...args: unknown[]) => mockTrackEvent(...args), })) -vi.mock('@/service/log', () => ({ - fetchWorkflowLogs: vi.fn(), -})) - vi.mock('@/hooks/use-theme', () => ({ __esModule: true, default: () => { @@ -89,38 +87,76 @@ vi.mock('@/context/app-context', () => ({ }), })) -// Mock useTimestamp -vi.mock('@/hooks/use-timestamp', () => ({ - __esModule: true, - default: () => ({ - formatTime: (timestamp: number, _format: string) => `formatted-${timestamp}`, - }), -})) - -// Mock useBreakpoints -vi.mock('@/hooks/use-breakpoints', () => ({ - __esModule: true, - default: () => 'pc', - MediaType: { - mobile: 'mobile', - pc: 'pc', - }, -})) - -// Mock BlockIcon -vi.mock('@/app/components/workflow/block-icon', () => ({ - __esModule: true, - default: () =>
BlockIcon
, -})) - // Mock WorkflowContextProvider vi.mock('@/app/components/workflow/context', () => ({ WorkflowContextProvider: ({ children }: { children: React.ReactNode }) => ( -
{children}
+ <>{children} ), })) -const mockedUseSWR = useSWR as unknown as MockedFunction +const mockedUseWorkflowLogs = useLogModule.useWorkflowLogs as MockedFunction + +// ============================================================================ +// Test Utilities +// ============================================================================ + +const createQueryClient = () => new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, +}) + +const renderWithQueryClient = (ui: React.ReactElement) => { + const queryClient = createQueryClient() + return render( + + {ui} + , + ) +} + +// ============================================================================ +// Mock Return Value Factory +// ============================================================================ + +const createMockQueryResult = ( + overrides: { data?: T, isLoading?: boolean, error?: Error | null } = {}, +): UseQueryResult => { + const isLoading = overrides.isLoading ?? false + const error = overrides.error ?? null + const data = overrides.data + + return { + data, + isLoading, + error, + refetch: vi.fn(), + isError: !!error, + isPending: isLoading, + isSuccess: !isLoading && !error && data !== undefined, + isFetching: isLoading, + isRefetching: false, + isLoadingError: false, + isRefetchError: false, + isInitialLoading: isLoading, + isPaused: false, + isEnabled: true, + status: isLoading ? 'pending' : error ? 'error' : 'success', + fetchStatus: isLoading ? 'fetching' : 'idle', + dataUpdatedAt: Date.now(), + errorUpdatedAt: 0, + failureCount: 0, + failureReason: null, + errorUpdateCount: 0, + isFetched: !isLoading, + isFetchedAfterMount: !isLoading, + isPlaceholderData: false, + isStale: false, + promise: Promise.resolve(data as T), + } as UseQueryResult +} // ============================================================================ // Test Data Factories @@ -195,6 +231,20 @@ const createMockLogsResponse = ( page: 1, }) +// ============================================================================ +// Type-safe Mock Helper +// ============================================================================ + +type WorkflowLogsParams = { + appId: string + params?: Record +} + +const getMockCallParams = (): WorkflowLogsParams | undefined => { + const lastCall = mockedUseWorkflowLogs.mock.calls.at(-1) + return lastCall?.[0] +} + // ============================================================================ // Tests // ============================================================================ @@ -213,45 +263,48 @@ describe('Logs Container', () => { // -------------------------------------------------------------------------- describe('Rendering', () => { it('should render without crashing', () => { - mockedUseSWR.mockReturnValue({ - data: createMockLogsResponse([], 0), - mutate: vi.fn(), - isValidating: false, - isLoading: false, - error: undefined, - }) + // Arrange + mockedUseWorkflowLogs.mockReturnValue( + createMockQueryResult({ + data: createMockLogsResponse([], 0), + }), + ) - render() + // Act + renderWithQueryClient() + // Assert expect(screen.getByText('appLog.workflowTitle')).toBeInTheDocument() }) it('should render title and subtitle', () => { - mockedUseSWR.mockReturnValue({ - data: createMockLogsResponse([], 0), - mutate: vi.fn(), - isValidating: false, - isLoading: false, - error: undefined, - }) + // Arrange + mockedUseWorkflowLogs.mockReturnValue( + createMockQueryResult({ + data: createMockLogsResponse([], 0), + }), + ) - render() + // Act + renderWithQueryClient() + // Assert expect(screen.getByText('appLog.workflowTitle')).toBeInTheDocument() expect(screen.getByText('appLog.workflowSubtitle')).toBeInTheDocument() }) it('should render Filter component', () => { - mockedUseSWR.mockReturnValue({ - data: createMockLogsResponse([], 0), - mutate: vi.fn(), - isValidating: false, - isLoading: false, - error: undefined, - }) + // Arrange + mockedUseWorkflowLogs.mockReturnValue( + createMockQueryResult({ + data: createMockLogsResponse([], 0), + }), + ) - render() + // Act + renderWithQueryClient() + // Assert expect(screen.getByPlaceholderText('common.operation.search')).toBeInTheDocument() }) }) @@ -261,30 +314,33 @@ describe('Logs Container', () => { // -------------------------------------------------------------------------- describe('Loading State', () => { it('should show loading spinner when data is undefined', () => { - mockedUseSWR.mockReturnValue({ - data: undefined, - mutate: vi.fn(), - isValidating: true, - isLoading: true, - error: undefined, - }) + // Arrange + mockedUseWorkflowLogs.mockReturnValue( + createMockQueryResult({ + data: undefined, + isLoading: true, + }), + ) - const { container } = render() + // Act + const { container } = renderWithQueryClient() + // Assert expect(container.querySelector('.spin-animation')).toBeInTheDocument() }) it('should not show loading spinner when data is available', () => { - mockedUseSWR.mockReturnValue({ - data: createMockLogsResponse([createMockWorkflowLog()], 1), - mutate: vi.fn(), - isValidating: false, - isLoading: false, - error: undefined, - }) + // Arrange + mockedUseWorkflowLogs.mockReturnValue( + createMockQueryResult({ + data: createMockLogsResponse([createMockWorkflowLog()], 1), + }), + ) - const { container } = render() + // Act + const { container } = renderWithQueryClient() + // Assert expect(container.querySelector('.spin-animation')).not.toBeInTheDocument() }) }) @@ -294,16 +350,17 @@ describe('Logs Container', () => { // -------------------------------------------------------------------------- describe('Empty State', () => { it('should render empty element when total is 0', () => { - mockedUseSWR.mockReturnValue({ - data: createMockLogsResponse([], 0), - mutate: vi.fn(), - isValidating: false, - isLoading: false, - error: undefined, - }) + // Arrange + mockedUseWorkflowLogs.mockReturnValue( + createMockQueryResult({ + data: createMockLogsResponse([], 0), + }), + ) - render() + // Act + renderWithQueryClient() + // Assert expect(screen.getByText('appLog.table.empty.element.title')).toBeInTheDocument() expect(screen.queryByRole('table')).not.toBeInTheDocument() }) @@ -313,20 +370,21 @@ describe('Logs Container', () => { // Data Fetching Tests // -------------------------------------------------------------------------- describe('Data Fetching', () => { - it('should call useSWR with correct URL and default params', () => { - mockedUseSWR.mockReturnValue({ - data: createMockLogsResponse([], 0), - mutate: vi.fn(), - isValidating: false, - isLoading: false, - error: undefined, - }) + it('should call useWorkflowLogs with correct appId and default params', () => { + // Arrange + mockedUseWorkflowLogs.mockReturnValue( + createMockQueryResult({ + data: createMockLogsResponse([], 0), + }), + ) - render() + // Act + renderWithQueryClient() - const keyArg = mockedUseSWR.mock.calls.at(-1)?.[0] as { url: string, params: Record } - expect(keyArg).toMatchObject({ - url: `/apps/${defaultProps.appDetail.id}/workflow-app-logs`, + // Assert + const callArg = getMockCallParams() + expect(callArg).toMatchObject({ + appId: defaultProps.appDetail.id, params: expect.objectContaining({ page: 1, detail: true, @@ -336,34 +394,36 @@ describe('Logs Container', () => { }) it('should include date filters for non-allTime periods', () => { - mockedUseSWR.mockReturnValue({ - data: createMockLogsResponse([], 0), - mutate: vi.fn(), - isValidating: false, - isLoading: false, - error: undefined, - }) + // Arrange + mockedUseWorkflowLogs.mockReturnValue( + createMockQueryResult({ + data: createMockLogsResponse([], 0), + }), + ) - render() + // Act + renderWithQueryClient() - const keyArg = mockedUseSWR.mock.calls.at(-1)?.[0] as { params?: Record } - expect(keyArg?.params).toHaveProperty('created_at__after') - expect(keyArg?.params).toHaveProperty('created_at__before') + // Assert + const callArg = getMockCallParams() + expect(callArg?.params).toHaveProperty('created_at__after') + expect(callArg?.params).toHaveProperty('created_at__before') }) it('should not include status param when status is all', () => { - mockedUseSWR.mockReturnValue({ - data: createMockLogsResponse([], 0), - mutate: vi.fn(), - isValidating: false, - isLoading: false, - error: undefined, - }) + // Arrange + mockedUseWorkflowLogs.mockReturnValue( + createMockQueryResult({ + data: createMockLogsResponse([], 0), + }), + ) - render() + // Act + renderWithQueryClient() - const keyArg = mockedUseSWR.mock.calls.at(-1)?.[0] as { params?: Record } - expect(keyArg?.params).not.toHaveProperty('status') + // Assert + const callArg = getMockCallParams() + expect(callArg?.params).not.toHaveProperty('status') }) }) @@ -372,24 +432,23 @@ describe('Logs Container', () => { // -------------------------------------------------------------------------- describe('Filter Integration', () => { it('should update query when selecting status filter', async () => { + // Arrange const user = userEvent.setup() - mockedUseSWR.mockReturnValue({ - data: createMockLogsResponse([], 0), - mutate: vi.fn(), - isValidating: false, - isLoading: false, - error: undefined, - }) + mockedUseWorkflowLogs.mockReturnValue( + createMockQueryResult({ + data: createMockLogsResponse([], 0), + }), + ) - render() + renderWithQueryClient() - // Click status filter + // Act await user.click(screen.getByText('All')) await user.click(await screen.findByText('Success')) - // Check that useSWR was called with updated params + // Assert await waitFor(() => { - const lastCall = mockedUseSWR.mock.calls.at(-1)?.[0] as { params?: Record } + const lastCall = getMockCallParams() expect(lastCall?.params).toMatchObject({ status: 'succeeded', }) @@ -397,46 +456,46 @@ describe('Logs Container', () => { }) it('should update query when selecting period filter', async () => { + // Arrange const user = userEvent.setup() - mockedUseSWR.mockReturnValue({ - data: createMockLogsResponse([], 0), - mutate: vi.fn(), - isValidating: false, - isLoading: false, - error: undefined, - }) + mockedUseWorkflowLogs.mockReturnValue( + createMockQueryResult({ + data: createMockLogsResponse([], 0), + }), + ) - render() + renderWithQueryClient() - // Click period filter + // Act await user.click(screen.getByText('appLog.filter.period.last7days')) await user.click(await screen.findByText('appLog.filter.period.allTime')) - // When period is allTime (9), date filters should be removed + // Assert await waitFor(() => { - const lastCall = mockedUseSWR.mock.calls.at(-1)?.[0] as { params?: Record } + const lastCall = getMockCallParams() expect(lastCall?.params).not.toHaveProperty('created_at__after') expect(lastCall?.params).not.toHaveProperty('created_at__before') }) }) it('should update query when typing keyword', async () => { + // Arrange const user = userEvent.setup() - mockedUseSWR.mockReturnValue({ - data: createMockLogsResponse([], 0), - mutate: vi.fn(), - isValidating: false, - isLoading: false, - error: undefined, - }) + mockedUseWorkflowLogs.mockReturnValue( + createMockQueryResult({ + data: createMockLogsResponse([], 0), + }), + ) - render() + renderWithQueryClient() + // Act const searchInput = screen.getByPlaceholderText('common.operation.search') await user.type(searchInput, 'test-keyword') + // Assert await waitFor(() => { - const lastCall = mockedUseSWR.mock.calls.at(-1)?.[0] as { params?: Record } + const lastCall = getMockCallParams() expect(lastCall?.params).toMatchObject({ keyword: 'test-keyword', }) @@ -449,36 +508,35 @@ describe('Logs Container', () => { // -------------------------------------------------------------------------- describe('Pagination', () => { it('should not render pagination when total is less than limit', () => { - mockedUseSWR.mockReturnValue({ - data: createMockLogsResponse([createMockWorkflowLog()], 1), - mutate: vi.fn(), - isValidating: false, - isLoading: false, - error: undefined, - }) + // Arrange + mockedUseWorkflowLogs.mockReturnValue( + createMockQueryResult({ + data: createMockLogsResponse([createMockWorkflowLog()], 1), + }), + ) - render() + // Act + renderWithQueryClient() - // Pagination component should not be rendered + // Assert expect(screen.queryByRole('navigation')).not.toBeInTheDocument() }) it('should render pagination when total exceeds limit', () => { + // Arrange const logs = Array.from({ length: APP_PAGE_LIMIT }, (_, i) => createMockWorkflowLog({ id: `log-${i}` })) - mockedUseSWR.mockReturnValue({ - data: createMockLogsResponse(logs, APP_PAGE_LIMIT + 10), - mutate: vi.fn(), - isValidating: false, - isLoading: false, - error: undefined, - }) + mockedUseWorkflowLogs.mockReturnValue( + createMockQueryResult({ + data: createMockLogsResponse(logs, APP_PAGE_LIMIT + 10), + }), + ) - render() + // Act + renderWithQueryClient() - // Should show pagination - checking for any pagination-related element - // The Pagination component renders page controls + // Assert expect(screen.getByRole('table')).toBeInTheDocument() }) }) @@ -488,37 +546,39 @@ describe('Logs Container', () => { // -------------------------------------------------------------------------- describe('List Rendering', () => { it('should render List component when data is available', () => { - mockedUseSWR.mockReturnValue({ - data: createMockLogsResponse([createMockWorkflowLog()], 1), - mutate: vi.fn(), - isValidating: false, - isLoading: false, - error: undefined, - }) + // Arrange + mockedUseWorkflowLogs.mockReturnValue( + createMockQueryResult({ + data: createMockLogsResponse([createMockWorkflowLog()], 1), + }), + ) - render() + // Act + renderWithQueryClient() + // Assert expect(screen.getByRole('table')).toBeInTheDocument() }) it('should display log data in table', () => { - mockedUseSWR.mockReturnValue({ - data: createMockLogsResponse([ - createMockWorkflowLog({ - workflow_run: createMockWorkflowRun({ - status: 'succeeded', - total_tokens: 500, + // Arrange + mockedUseWorkflowLogs.mockReturnValue( + createMockQueryResult({ + data: createMockLogsResponse([ + createMockWorkflowLog({ + workflow_run: createMockWorkflowRun({ + status: 'succeeded', + total_tokens: 500, + }), }), - }), - ], 1), - mutate: vi.fn(), - isValidating: false, - isLoading: false, - error: undefined, - }) + ], 1), + }), + ) - render() + // Act + renderWithQueryClient() + // Assert expect(screen.getByText('Success')).toBeInTheDocument() expect(screen.getByText('500')).toBeInTheDocument() }) @@ -541,52 +601,54 @@ describe('Logs Container', () => { // -------------------------------------------------------------------------- describe('Edge Cases', () => { it('should handle different app modes', () => { - mockedUseSWR.mockReturnValue({ - data: createMockLogsResponse([createMockWorkflowLog()], 1), - mutate: vi.fn(), - isValidating: false, - isLoading: false, - error: undefined, - }) + // Arrange + mockedUseWorkflowLogs.mockReturnValue( + createMockQueryResult({ + data: createMockLogsResponse([createMockWorkflowLog()], 1), + }), + ) const chatApp = createMockApp({ mode: 'advanced-chat' as AppModeEnum }) - render() + // Act + renderWithQueryClient() - // Should render without trigger column + // Assert expect(screen.queryByText('appLog.table.header.triggered_from')).not.toBeInTheDocument() }) - it('should handle error state from useSWR', () => { - mockedUseSWR.mockReturnValue({ - data: undefined, - mutate: vi.fn(), - isValidating: false, - isLoading: false, - error: new Error('Failed to fetch'), - }) + it('should handle error state from useWorkflowLogs', () => { + // Arrange + mockedUseWorkflowLogs.mockReturnValue( + createMockQueryResult({ + data: undefined, + error: new Error('Failed to fetch'), + }), + ) - const { container } = render() + // Act + const { container } = renderWithQueryClient() - // Should show loading state when data is undefined + // Assert - should show loading state when data is undefined expect(container.querySelector('.spin-animation')).toBeInTheDocument() }) it('should handle app with different ID', () => { - mockedUseSWR.mockReturnValue({ - data: createMockLogsResponse([], 0), - mutate: vi.fn(), - isValidating: false, - isLoading: false, - error: undefined, - }) + // Arrange + mockedUseWorkflowLogs.mockReturnValue( + createMockQueryResult({ + data: createMockLogsResponse([], 0), + }), + ) const customApp = createMockApp({ id: 'custom-app-123' }) - render() + // Act + renderWithQueryClient() - const keyArg = mockedUseSWR.mock.calls.at(-1)?.[0] as { url: string } - expect(keyArg?.url).toBe('/apps/custom-app-123/workflow-app-logs') + // Assert + const callArg = getMockCallParams() + expect(callArg?.appId).toBe('custom-app-123') }) }) }) diff --git a/web/app/components/app/workflow-log/index.tsx b/web/app/components/app/workflow-log/index.tsx index 1390f2d435..12438d6d17 100644 --- a/web/app/components/app/workflow-log/index.tsx +++ b/web/app/components/app/workflow-log/index.tsx @@ -9,13 +9,12 @@ import { omit } from 'lodash-es' import * as React from 'react' import { useState } from 'react' import { useTranslation } from 'react-i18next' -import useSWR from 'swr' import EmptyElement from '@/app/components/app/log/empty-element' import Loading from '@/app/components/base/loading' import Pagination from '@/app/components/base/pagination' import { APP_PAGE_LIMIT } from '@/config' import { useAppContext } from '@/context/app-context' -import { fetchWorkflowLogs } from '@/service/log' +import { useWorkflowLogs } from '@/service/use-log' import Filter, { TIME_PERIOD_MAPPING } from './filter' import List from './list' @@ -55,10 +54,10 @@ const Logs: FC = ({ appDetail }) => { ...omit(debouncedQueryParams, ['period', 'status']), } - const { data: workflowLogs, mutate } = useSWR({ - url: `/apps/${appDetail.id}/workflow-app-logs`, + const { data: workflowLogs, refetch: mutate } = useWorkflowLogs({ + appId: appDetail.id, params: query, - }, fetchWorkflowLogs) + }) const total = workflowLogs?.total return ( diff --git a/web/app/components/base/skeleton/index.tsx b/web/app/components/base/skeleton/index.tsx index 9cd7e3f09c..cbb5a3d7c3 100644 --- a/web/app/components/base/skeleton/index.tsx +++ b/web/app/components/base/skeleton/index.tsx @@ -36,7 +36,8 @@ export const SkeletonPoint: FC = (props) => {
·
) } -/** Usage +/** + * Usage * * * diff --git a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/model-display.tsx b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/model-display.tsx index e9e8ec7525..d8a4fabac0 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/model-display.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/model-display.tsx @@ -6,20 +6,22 @@ type ModelDisplayProps = { } const ModelDisplay = ({ currentModel, modelId }: ModelDisplayProps) => { - return currentModel ? ( - - ) : ( -
-
- {modelId} -
-
- ) + return currentModel + ? ( + + ) + : ( +
+
+ {modelId} +
+
+ ) } export default ModelDisplay diff --git a/web/models/log.ts b/web/models/log.ts index 8c022ee6b2..d15d6d6688 100644 --- a/web/models/log.ts +++ b/web/models/log.ts @@ -6,21 +6,6 @@ import type { } from '@/app/components/workflow/types' import type { VisionFile } from '@/types/app' -// Log type contains key:string conversation_id:string created_at:string question:string answer:string -export type Conversation = { - id: string - key: string - conversationId: string - question: string - answer: string - userRate: number - adminRate: number -} - -export type ConversationListResponse = { - logs: Conversation[] -} - export const CompletionParams = ['temperature', 'top_p', 'presence_penalty', 'max_token', 'stop', 'frequency_penalty'] as const export type CompletionParamType = typeof CompletionParams[number] diff --git a/web/package.json b/web/package.json index ce2e59e022..baecd2c5c7 100644 --- a/web/package.json +++ b/web/package.json @@ -38,6 +38,7 @@ "gen:i18n-types": "node ./i18n-config/generate-i18n-types.js", "check:i18n-types": "node ./i18n-config/check-i18n-sync.js", "test": "vitest run", + "test:coverage": "vitest run --coverage", "test:watch": "vitest --watch", "analyze-component": "node testing/analyze-component.js", "storybook": "storybook dev -p 6006", diff --git a/web/service/log.ts b/web/service/log.ts index aa0be7ac3b..a540cea22c 100644 --- a/web/service/log.ts +++ b/web/service/log.ts @@ -1,80 +1,38 @@ -import type { Fetcher } from 'swr' import type { AgentLogDetailRequest, AgentLogDetailResponse, - AnnotationsCountResponse, - ChatConversationFullDetailResponse, - ChatConversationsRequest, - ChatConversationsResponse, ChatMessagesRequest, ChatMessagesResponse, - CompletionConversationFullDetailResponse, - CompletionConversationsRequest, - CompletionConversationsResponse, - ConversationListResponse, LogMessageAnnotationsRequest, LogMessageAnnotationsResponse, LogMessageFeedbacksRequest, LogMessageFeedbacksResponse, - WorkflowLogsResponse, WorkflowRunDetailResponse, } from '@/models/log' import type { NodeTracingListResponse } from '@/types/workflow' import { get, post } from './base' -export const fetchConversationList: Fetcher }> = ({ appId, params }) => { - return get(`/console/api/apps/${appId}/messages`, params) -} - -// (Text Generation Application) Session List -export const fetchCompletionConversations: Fetcher = ({ url, params }) => { - return get(url, { params }) -} - -// (Text Generation Application) Session Detail -export const fetchCompletionConversationDetail: Fetcher = ({ url }) => { - return get(url, {}) -} - -// (Chat Application) Session List -export const fetchChatConversations: Fetcher = ({ url, params }) => { - return get(url, { params }) -} - -// (Chat Application) Session Detail -export const fetchChatConversationDetail: Fetcher = ({ url }) => { - return get(url, {}) -} - // (Chat Application) Message list in one session -export const fetchChatMessages: Fetcher = ({ url, params }) => { +export const fetchChatMessages = ({ url, params }: { url: string, params: ChatMessagesRequest }): Promise => { return get(url, { params }) } -export const updateLogMessageFeedbacks: Fetcher = ({ url, body }) => { +export const updateLogMessageFeedbacks = ({ url, body }: { url: string, body: LogMessageFeedbacksRequest }): Promise => { return post(url, { body }) } -export const updateLogMessageAnnotations: Fetcher = ({ url, body }) => { +export const updateLogMessageAnnotations = ({ url, body }: { url: string, body: LogMessageAnnotationsRequest }): Promise => { return post(url, { body }) } -export const fetchAnnotationsCount: Fetcher = ({ url }) => { - return get(url) -} - -export const fetchWorkflowLogs: Fetcher }> = ({ url, params }) => { - return get(url, { params }) -} - -export const fetchRunDetail = (url: string) => { +export const fetchRunDetail = (url: string): Promise => { return get(url) } -export const fetchTracingList: Fetcher = ({ url }) => { +export const fetchTracingList = ({ url }: { url: string }): Promise => { return get(url) } -export const fetchAgentLogDetail = ({ appID, params }: { appID: string, params: AgentLogDetailRequest }) => { +export const fetchAgentLogDetail = ({ appID, params }: { appID: string, params: AgentLogDetailRequest }): Promise => { return get(`/apps/${appID}/agent/logs`, { params }) } diff --git a/web/service/use-log.ts b/web/service/use-log.ts new file mode 100644 index 0000000000..b120adda2f --- /dev/null +++ b/web/service/use-log.ts @@ -0,0 +1,89 @@ +import type { + AnnotationsCountResponse, + ChatConversationFullDetailResponse, + ChatConversationsRequest, + ChatConversationsResponse, + CompletionConversationFullDetailResponse, + CompletionConversationsRequest, + CompletionConversationsResponse, + WorkflowLogsResponse, +} from '@/models/log' +import { useQuery } from '@tanstack/react-query' +import { get } from './base' + +const NAME_SPACE = 'log' + +// ============ Annotations Count ============ + +export const useAnnotationsCount = (appId: string) => { + return useQuery({ + queryKey: [NAME_SPACE, 'annotations-count', appId], + queryFn: () => get(`/apps/${appId}/annotations/count`), + enabled: !!appId, + }) +} + +// ============ Chat Conversations ============ + +type ChatConversationsParams = { + appId: string + params?: Partial +} + +export const useChatConversations = ({ appId, params }: ChatConversationsParams) => { + return useQuery({ + queryKey: [NAME_SPACE, 'chat-conversations', appId, params], + queryFn: () => get(`/apps/${appId}/chat-conversations`, { params }), + enabled: !!appId, + }) +} + +// ============ Completion Conversations ============ + +type CompletionConversationsParams = { + appId: string + params?: Partial +} + +export const useCompletionConversations = ({ appId, params }: CompletionConversationsParams) => { + return useQuery({ + queryKey: [NAME_SPACE, 'completion-conversations', appId, params], + queryFn: () => get(`/apps/${appId}/completion-conversations`, { params }), + enabled: !!appId, + }) +} + +// ============ Chat Conversation Detail ============ + +export const useChatConversationDetail = (appId?: string, conversationId?: string) => { + return useQuery({ + queryKey: [NAME_SPACE, 'chat-conversation-detail', appId, conversationId], + queryFn: () => get(`/apps/${appId}/chat-conversations/${conversationId}`), + enabled: !!appId && !!conversationId, + }) +} + +// ============ Completion Conversation Detail ============ + +export const useCompletionConversationDetail = (appId?: string, conversationId?: string) => { + return useQuery({ + queryKey: [NAME_SPACE, 'completion-conversation-detail', appId, conversationId], + queryFn: () => get(`/apps/${appId}/completion-conversations/${conversationId}`), + enabled: !!appId && !!conversationId, + }) +} + +// ============ Workflow Logs ============ + +type WorkflowLogsParams = { + appId: string + params?: Record +} + +export const useWorkflowLogs = ({ appId, params }: WorkflowLogsParams) => { + return useQuery({ + queryKey: [NAME_SPACE, 'workflow-logs', appId, params], + queryFn: () => get(`/apps/${appId}/workflow-app-logs`, { params }), + enabled: !!appId, + }) +} diff --git a/web/testing/testing.md b/web/testing/testing.md index a2c8399d45..08fc716cf3 100644 --- a/web/testing/testing.md +++ b/web/testing/testing.md @@ -21,10 +21,10 @@ pnpm test pnpm test:watch # Generate coverage report -pnpm test -- --coverage +pnpm test:coverage # Run specific file -pnpm test -- path/to/file.spec.tsx +pnpm test path/to/file.spec.tsx ``` ## Project Test Setup From 0f41924db48596058edc1a2ebbe49eac23433c9e Mon Sep 17 00:00:00 2001 From: Joel Date: Wed, 24 Dec 2025 16:17:59 +0800 Subject: [PATCH 03/11] chore: some tests (#30084) Co-authored-by: yyh --- .../base/feature-panel/index.spec.tsx | 71 +++++ .../base/feature-panel/index.tsx | 4 +- .../apps-full-in-dialog/index.spec.tsx | 274 ++++++++++++++++++ .../billing/pricing/assets/index.spec.tsx | 64 ++++ .../billing/pricing/footer.spec.tsx | 68 +++++ .../billing/pricing/header.spec.tsx | 72 +++++ .../components/billing/pricing/index.spec.tsx | 119 ++++++++ .../pricing/plan-switcher/index.spec.tsx | 109 +++++++ .../plan-range-switcher.spec.tsx | 81 ++++++ .../pricing/plan-switcher/tab.spec.tsx | 95 ++++++ .../components/billing/progress-bar/index.tsx | 1 + 11 files changed, 956 insertions(+), 2 deletions(-) create mode 100644 web/app/components/app/configuration/base/feature-panel/index.spec.tsx create mode 100644 web/app/components/billing/apps-full-in-dialog/index.spec.tsx create mode 100644 web/app/components/billing/pricing/assets/index.spec.tsx create mode 100644 web/app/components/billing/pricing/footer.spec.tsx create mode 100644 web/app/components/billing/pricing/header.spec.tsx create mode 100644 web/app/components/billing/pricing/index.spec.tsx create mode 100644 web/app/components/billing/pricing/plan-switcher/index.spec.tsx create mode 100644 web/app/components/billing/pricing/plan-switcher/plan-range-switcher.spec.tsx create mode 100644 web/app/components/billing/pricing/plan-switcher/tab.spec.tsx diff --git a/web/app/components/app/configuration/base/feature-panel/index.spec.tsx b/web/app/components/app/configuration/base/feature-panel/index.spec.tsx new file mode 100644 index 0000000000..7e1b661399 --- /dev/null +++ b/web/app/components/app/configuration/base/feature-panel/index.spec.tsx @@ -0,0 +1,71 @@ +import { render, screen } from '@testing-library/react' +import FeaturePanel from './index' + +describe('FeaturePanel', () => { + // Rendering behavior for standard layout. + describe('Rendering', () => { + it('should render the title and children when provided', () => { + // Arrange + render( + +
Panel Body
+
, + ) + + // Assert + expect(screen.getByText('Panel Title')).toBeInTheDocument() + expect(screen.getByText('Panel Body')).toBeInTheDocument() + }) + }) + + // Prop-driven presentation details like icons, actions, and spacing. + describe('Props', () => { + it('should render header icon and right slot and apply header border', () => { + // Arrange + render( + Icon} + headerRight={} + hasHeaderBottomBorder={true} + />, + ) + + // Assert + expect(screen.getByText('Icon')).toBeInTheDocument() + expect(screen.getByText('Action')).toBeInTheDocument() + const header = screen.getByTestId('feature-panel-header') + expect(header).toHaveClass('border-b') + }) + + it('should apply custom className and remove padding when noBodySpacing is true', () => { + // Arrange + const { container } = render( + +
Body
+
, + ) + + // Assert + const root = container.firstElementChild as HTMLElement + expect(root).toHaveClass('custom-panel') + expect(root).toHaveClass('pb-0') + const body = screen.getByTestId('feature-panel-body') + expect(body).not.toHaveClass('mt-1') + expect(body).not.toHaveClass('px-3') + }) + }) + + // Edge cases when optional content is missing. + describe('Edge Cases', () => { + it('should not render the body wrapper when children is undefined', () => { + // Arrange + render() + + // Assert + expect(screen.queryByText('No Body')).toBeInTheDocument() + expect(screen.queryByText('Panel Body')).not.toBeInTheDocument() + expect(screen.queryByTestId('feature-panel-body')).not.toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/app/configuration/base/feature-panel/index.tsx b/web/app/components/app/configuration/base/feature-panel/index.tsx index 20c4a8dc17..06ae2ab10a 100644 --- a/web/app/components/app/configuration/base/feature-panel/index.tsx +++ b/web/app/components/app/configuration/base/feature-panel/index.tsx @@ -25,7 +25,7 @@ const FeaturePanel: FC = ({ return (
{/* Header */} -
+
{headerIcon &&
{headerIcon}
} @@ -38,7 +38,7 @@ const FeaturePanel: FC = ({
{/* Body */} {children && ( -
+
{children}
)} diff --git a/web/app/components/billing/apps-full-in-dialog/index.spec.tsx b/web/app/components/billing/apps-full-in-dialog/index.spec.tsx new file mode 100644 index 0000000000..a11b582b0f --- /dev/null +++ b/web/app/components/billing/apps-full-in-dialog/index.spec.tsx @@ -0,0 +1,274 @@ +import type { Mock } from 'vitest' +import type { UsagePlanInfo } from '@/app/components/billing/type' +import type { AppContextValue } from '@/context/app-context' +import type { ProviderContextState } from '@/context/provider-context' +import type { ICurrentWorkspace, LangGeniusVersionResponse, UserProfileResponse } from '@/models/common' +import { render, screen } from '@testing-library/react' +import { Plan } from '@/app/components/billing/type' +import { mailToSupport } from '@/app/components/header/utils/util' +import { useAppContext } from '@/context/app-context' +import { baseProviderContextValue, useProviderContext } from '@/context/provider-context' +import AppsFull from './index' + +vi.mock('@/context/app-context', () => ({ + useAppContext: vi.fn(), +})) + +vi.mock('@/context/provider-context', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useProviderContext: vi.fn(), + } +}) + +vi.mock('@/context/modal-context', () => ({ + useModalContext: () => ({ + setShowPricingModal: vi.fn(), + }), +})) + +vi.mock('@/app/components/header/utils/util', () => ({ + mailToSupport: vi.fn(), +})) + +const buildUsage = (overrides: Partial = {}): UsagePlanInfo => ({ + buildApps: 0, + teamMembers: 0, + annotatedResponse: 0, + documentsUploadQuota: 0, + apiRateLimit: 0, + triggerEvents: 0, + vectorSpace: 0, + ...overrides, +}) + +const buildProviderContext = (overrides: Partial = {}): ProviderContextState => ({ + ...baseProviderContextValue, + plan: { + ...baseProviderContextValue.plan, + type: Plan.sandbox, + usage: buildUsage({ buildApps: 2 }), + total: buildUsage({ buildApps: 10 }), + reset: { + apiRateLimit: null, + triggerEvents: null, + }, + }, + ...overrides, +}) + +const buildAppContext = (overrides: Partial = {}): AppContextValue => { + const userProfile: UserProfileResponse = { + id: 'user-id', + name: 'Test User', + email: 'user@example.com', + avatar: '', + avatar_url: '', + is_password_set: false, + } + const currentWorkspace: ICurrentWorkspace = { + id: 'workspace-id', + name: 'Workspace', + plan: '', + status: '', + created_at: 0, + role: 'normal', + providers: [], + } + const langGeniusVersionInfo: LangGeniusVersionResponse = { + current_env: '', + current_version: '1.0.0', + latest_version: '', + release_date: '', + release_notes: '', + version: '', + can_auto_update: false, + } + const base: Omit = { + userProfile, + currentWorkspace, + isCurrentWorkspaceManager: false, + isCurrentWorkspaceOwner: false, + isCurrentWorkspaceEditor: false, + isCurrentWorkspaceDatasetOperator: false, + mutateUserProfile: vi.fn(), + mutateCurrentWorkspace: vi.fn(), + langGeniusVersionInfo, + isLoadingCurrentWorkspace: false, + } + const useSelector: AppContextValue['useSelector'] = selector => selector({ ...base, useSelector }) + return { + ...base, + useSelector, + ...overrides, + } +} + +describe('AppsFull', () => { + beforeEach(() => { + vi.clearAllMocks() + ;(useProviderContext as Mock).mockReturnValue(buildProviderContext()) + ;(useAppContext as Mock).mockReturnValue(buildAppContext()) + ;(mailToSupport as Mock).mockReturnValue('mailto:support@example.com') + }) + + // Rendering behavior for non-team plans. + describe('Rendering', () => { + it('should render the sandbox messaging and upgrade button', () => { + // Act + render() + + // Assert + expect(screen.getByText('billing.apps.fullTip1')).toBeInTheDocument() + expect(screen.getByText('billing.apps.fullTip1des')).toBeInTheDocument() + expect(screen.getByText('billing.upgradeBtn.encourageShort')).toBeInTheDocument() + expect(screen.getByText('2/10')).toBeInTheDocument() + }) + }) + + // Prop-driven behavior for team plans and contact CTA. + describe('Props', () => { + it('should render team messaging and contact button for non-sandbox plans', () => { + // Arrange + ;(useProviderContext as Mock).mockReturnValue(buildProviderContext({ + plan: { + ...baseProviderContextValue.plan, + type: Plan.team, + usage: buildUsage({ buildApps: 8 }), + total: buildUsage({ buildApps: 10 }), + reset: { + apiRateLimit: null, + triggerEvents: null, + }, + }, + })) + render() + + // Assert + expect(screen.getByText('billing.apps.fullTip2')).toBeInTheDocument() + expect(screen.getByText('billing.apps.fullTip2des')).toBeInTheDocument() + expect(screen.queryByText('billing.upgradeBtn.encourageShort')).not.toBeInTheDocument() + expect(screen.getByRole('link', { name: 'billing.apps.contactUs' })).toHaveAttribute('href', 'mailto:support@example.com') + expect(mailToSupport).toHaveBeenCalledWith('user@example.com', Plan.team, '1.0.0') + }) + + it('should render upgrade button for professional plans', () => { + // Arrange + ;(useProviderContext as Mock).mockReturnValue(buildProviderContext({ + plan: { + ...baseProviderContextValue.plan, + type: Plan.professional, + usage: buildUsage({ buildApps: 4 }), + total: buildUsage({ buildApps: 10 }), + reset: { + apiRateLimit: null, + triggerEvents: null, + }, + }, + })) + + // Act + render() + + // Assert + expect(screen.getByText('billing.apps.fullTip1')).toBeInTheDocument() + expect(screen.getByText('billing.upgradeBtn.encourageShort')).toBeInTheDocument() + expect(screen.queryByText('billing.apps.contactUs')).not.toBeInTheDocument() + }) + + it('should render contact button for enterprise plans', () => { + // Arrange + ;(useProviderContext as Mock).mockReturnValue(buildProviderContext({ + plan: { + ...baseProviderContextValue.plan, + type: Plan.enterprise, + usage: buildUsage({ buildApps: 9 }), + total: buildUsage({ buildApps: 10 }), + reset: { + apiRateLimit: null, + triggerEvents: null, + }, + }, + })) + + // Act + render() + + // Assert + expect(screen.getByText('billing.apps.fullTip1')).toBeInTheDocument() + expect(screen.queryByText('billing.upgradeBtn.encourageShort')).not.toBeInTheDocument() + expect(screen.getByRole('link', { name: 'billing.apps.contactUs' })).toHaveAttribute('href', 'mailto:support@example.com') + expect(mailToSupport).toHaveBeenCalledWith('user@example.com', Plan.enterprise, '1.0.0') + }) + }) + + // Edge cases for progress color thresholds. + describe('Edge Cases', () => { + it('should use the success color when usage is below 50%', () => { + // Arrange + ;(useProviderContext as Mock).mockReturnValue(buildProviderContext({ + plan: { + ...baseProviderContextValue.plan, + type: Plan.sandbox, + usage: buildUsage({ buildApps: 2 }), + total: buildUsage({ buildApps: 5 }), + reset: { + apiRateLimit: null, + triggerEvents: null, + }, + }, + })) + + // Act + render() + + // Assert + expect(screen.getByTestId('billing-progress-bar')).toHaveClass('bg-components-progress-bar-progress-solid') + }) + + it('should use the warning color when usage is between 50% and 80%', () => { + // Arrange + ;(useProviderContext as Mock).mockReturnValue(buildProviderContext({ + plan: { + ...baseProviderContextValue.plan, + type: Plan.sandbox, + usage: buildUsage({ buildApps: 6 }), + total: buildUsage({ buildApps: 10 }), + reset: { + apiRateLimit: null, + triggerEvents: null, + }, + }, + })) + + // Act + render() + + // Assert + expect(screen.getByTestId('billing-progress-bar')).toHaveClass('bg-components-progress-warning-progress') + }) + + it('should use the error color when usage is 80% or higher', () => { + // Arrange + ;(useProviderContext as Mock).mockReturnValue(buildProviderContext({ + plan: { + ...baseProviderContextValue.plan, + type: Plan.sandbox, + usage: buildUsage({ buildApps: 8 }), + total: buildUsage({ buildApps: 10 }), + reset: { + apiRateLimit: null, + triggerEvents: null, + }, + }, + })) + + // Act + render() + + // Assert + expect(screen.getByTestId('billing-progress-bar')).toHaveClass('bg-components-progress-error-progress') + }) + }) +}) diff --git a/web/app/components/billing/pricing/assets/index.spec.tsx b/web/app/components/billing/pricing/assets/index.spec.tsx new file mode 100644 index 0000000000..7980f9a182 --- /dev/null +++ b/web/app/components/billing/pricing/assets/index.spec.tsx @@ -0,0 +1,64 @@ +import { render } from '@testing-library/react' +import { + Cloud, + Community, + Enterprise, + EnterpriseNoise, + NoiseBottom, + NoiseTop, + Premium, + PremiumNoise, + Professional, + Sandbox, + SelfHosted, + Team, +} from './index' + +describe('Pricing Assets', () => { + // Rendering: each asset should render an svg. + describe('Rendering', () => { + it('should render static assets without crashing', () => { + // Arrange + const assets = [ + , + , + , + , + , + , + , + , + , + , + ] + + // Act / Assert + assets.forEach((asset) => { + const { container, unmount } = render(asset) + expect(container.querySelector('svg')).toBeInTheDocument() + unmount() + }) + }) + }) + + // Props: active state should change fill color for selectable assets. + describe('Props', () => { + it('should render active state for Cloud', () => { + // Arrange + const { container } = render() + + // Assert + const rects = Array.from(container.querySelectorAll('rect')) + expect(rects.some(rect => rect.getAttribute('fill') === 'var(--color-saas-dify-blue-accessible)')).toBe(true) + }) + + it('should render inactive state for SelfHosted', () => { + // Arrange + const { container } = render() + + // Assert + const rects = Array.from(container.querySelectorAll('rect')) + expect(rects.some(rect => rect.getAttribute('fill') === 'var(--color-text-primary)')).toBe(true) + }) + }) +}) diff --git a/web/app/components/billing/pricing/footer.spec.tsx b/web/app/components/billing/pricing/footer.spec.tsx new file mode 100644 index 0000000000..f8e7965f5e --- /dev/null +++ b/web/app/components/billing/pricing/footer.spec.tsx @@ -0,0 +1,68 @@ +import { render, screen } from '@testing-library/react' +import * as React from 'react' +import { CategoryEnum } from '.' +import Footer from './footer' + +let mockTranslations: Record = {} + +vi.mock('next/link', () => ({ + default: ({ children, href, className, target }: { children: React.ReactNode, href: string, className?: string, target?: string }) => ( + + {children} + + ), +})) + +vi.mock('react-i18next', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useTranslation: () => ({ + t: (key: string) => mockTranslations[key] ?? key, + }), + } +}) + +describe('Footer', () => { + beforeEach(() => { + vi.clearAllMocks() + mockTranslations = {} + }) + + // Rendering behavior + describe('Rendering', () => { + it('should render tax tips and comparison link when in cloud category', () => { + // Arrange + render(
) + + // Assert + expect(screen.getByText('billing.plansCommon.taxTip')).toBeInTheDocument() + expect(screen.getByText('billing.plansCommon.taxTipSecond')).toBeInTheDocument() + expect(screen.getByTestId('pricing-link')).toHaveAttribute('href', 'https://dify.ai/pricing#plans-and-features') + expect(screen.getByText('billing.plansCommon.comparePlanAndFeatures')).toBeInTheDocument() + }) + }) + + // Prop-driven behavior + describe('Props', () => { + it('should hide tax tips when category is self-hosted', () => { + // Arrange + render(
) + + // Assert + expect(screen.queryByText('billing.plansCommon.taxTip')).not.toBeInTheDocument() + expect(screen.queryByText('billing.plansCommon.taxTipSecond')).not.toBeInTheDocument() + }) + }) + + // Edge case rendering behavior + describe('Edge Cases', () => { + it('should render link even when pricing URL is empty', () => { + // Arrange + render(
) + + // Assert + expect(screen.getByTestId('pricing-link')).toHaveAttribute('href', '') + }) + }) +}) diff --git a/web/app/components/billing/pricing/header.spec.tsx b/web/app/components/billing/pricing/header.spec.tsx new file mode 100644 index 0000000000..0395e5dd48 --- /dev/null +++ b/web/app/components/billing/pricing/header.spec.tsx @@ -0,0 +1,72 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import * as React from 'react' +import Header from './header' + +let mockTranslations: Record = {} + +vi.mock('react-i18next', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useTranslation: () => ({ + t: (key: string) => mockTranslations[key] ?? key, + }), + } +}) + +describe('Header', () => { + beforeEach(() => { + vi.clearAllMocks() + mockTranslations = {} + }) + + // Rendering behavior + describe('Rendering', () => { + it('should render title and description translations', () => { + // Arrange + const handleClose = vi.fn() + + // Act + render(
) + + // Assert + expect(screen.getByText('billing.plansCommon.title.plans')).toBeInTheDocument() + expect(screen.getByText('billing.plansCommon.title.description')).toBeInTheDocument() + expect(screen.getByRole('button')).toBeInTheDocument() + }) + }) + + // Prop-driven behavior + describe('Props', () => { + it('should invoke onClose when close button is clicked', () => { + // Arrange + const handleClose = vi.fn() + render(
) + + // Act + fireEvent.click(screen.getByRole('button')) + + // Assert + expect(handleClose).toHaveBeenCalledTimes(1) + }) + }) + + // Edge case rendering behavior + describe('Edge Cases', () => { + it('should render structure when translations are empty strings', () => { + // Arrange + mockTranslations = { + 'billing.plansCommon.title.plans': '', + 'billing.plansCommon.title.description': '', + } + + // Act + const { container } = render(
) + + // Assert + expect(container.querySelector('span')).toBeInTheDocument() + expect(container.querySelector('p')).toBeInTheDocument() + expect(screen.getByRole('button')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/billing/pricing/index.spec.tsx b/web/app/components/billing/pricing/index.spec.tsx new file mode 100644 index 0000000000..141c2d9c96 --- /dev/null +++ b/web/app/components/billing/pricing/index.spec.tsx @@ -0,0 +1,119 @@ +import type { Mock } from 'vitest' +import type { UsagePlanInfo } from '../type' +import { fireEvent, render, screen } from '@testing-library/react' +import { useKeyPress } from 'ahooks' +import * as React from 'react' +import { useAppContext } from '@/context/app-context' +import { useGetPricingPageLanguage } from '@/context/i18n' +import { useProviderContext } from '@/context/provider-context' +import { Plan } from '../type' +import Pricing from './index' + +let mockTranslations: Record = {} +let mockLanguage: string | null = 'en' + +vi.mock('next/link', () => ({ + default: ({ children, href, className, target }: { children: React.ReactNode, href: string, className?: string, target?: string }) => ( + + {children} + + ), +})) + +vi.mock('ahooks', () => ({ + useKeyPress: vi.fn(), +})) + +vi.mock('@/context/app-context', () => ({ + useAppContext: vi.fn(), +})) + +vi.mock('@/context/provider-context', () => ({ + useProviderContext: vi.fn(), +})) + +vi.mock('@/context/i18n', () => ({ + useGetPricingPageLanguage: vi.fn(), +})) + +vi.mock('react-i18next', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useTranslation: () => ({ + t: (key: string, options?: { returnObjects?: boolean }) => { + if (options?.returnObjects) + return mockTranslations[key] ?? [] + return mockTranslations[key] ?? key + }, + }), + Trans: ({ i18nKey }: { i18nKey: string }) => {i18nKey}, + } +}) + +const buildUsage = (): UsagePlanInfo => ({ + buildApps: 0, + teamMembers: 0, + annotatedResponse: 0, + documentsUploadQuota: 0, + apiRateLimit: 0, + triggerEvents: 0, + vectorSpace: 0, +}) + +describe('Pricing', () => { + beforeEach(() => { + vi.clearAllMocks() + mockTranslations = {} + mockLanguage = 'en' + ;(useAppContext as Mock).mockReturnValue({ isCurrentWorkspaceManager: true }) + ;(useProviderContext as Mock).mockReturnValue({ + plan: { + type: Plan.sandbox, + usage: buildUsage(), + total: buildUsage(), + }, + }) + ;(useGetPricingPageLanguage as Mock).mockImplementation(() => mockLanguage) + }) + + // Rendering behavior + describe('Rendering', () => { + it('should render pricing header and localized footer link', () => { + // Arrange + render() + + // Assert + expect(screen.getByText('billing.plansCommon.title.plans')).toBeInTheDocument() + expect(screen.getByTestId('pricing-link')).toHaveAttribute('href', 'https://dify.ai/en/pricing#plans-and-features') + }) + }) + + // Prop-driven behavior + describe('Props', () => { + it('should register esc key handler and allow switching categories', () => { + // Arrange + const handleCancel = vi.fn() + render() + + // Act + fireEvent.click(screen.getByText('billing.plansCommon.self')) + + // Assert + expect(useKeyPress).toHaveBeenCalledWith(['esc'], handleCancel) + expect(screen.queryByRole('switch')).not.toBeInTheDocument() + }) + }) + + // Edge case rendering behavior + describe('Edge Cases', () => { + it('should fall back to default pricing URL when language is empty', () => { + // Arrange + mockLanguage = '' + render() + + // Assert + expect(screen.getByTestId('pricing-link')).toHaveAttribute('href', 'https://dify.ai/pricing#plans-and-features') + }) + }) +}) diff --git a/web/app/components/billing/pricing/plan-switcher/index.spec.tsx b/web/app/components/billing/pricing/plan-switcher/index.spec.tsx new file mode 100644 index 0000000000..641d359bfd --- /dev/null +++ b/web/app/components/billing/pricing/plan-switcher/index.spec.tsx @@ -0,0 +1,109 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import * as React from 'react' +import { CategoryEnum } from '../index' +import PlanSwitcher from './index' +import { PlanRange } from './plan-range-switcher' + +let mockTranslations: Record = {} + +vi.mock('react-i18next', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useTranslation: () => ({ + t: (key: string) => mockTranslations[key] ?? key, + }), + } +}) + +describe('PlanSwitcher', () => { + beforeEach(() => { + vi.clearAllMocks() + mockTranslations = {} + }) + + // Rendering behavior + describe('Rendering', () => { + it('should render category tabs and plan range switcher for cloud', () => { + // Arrange + render( + , + ) + + // Assert + expect(screen.getByText('billing.plansCommon.cloud')).toBeInTheDocument() + expect(screen.getByText('billing.plansCommon.self')).toBeInTheDocument() + expect(screen.getByRole('switch')).toBeInTheDocument() + }) + }) + + // Prop-driven behavior + describe('Props', () => { + it('should call onChangeCategory when selecting a tab', () => { + // Arrange + const handleChangeCategory = vi.fn() + render( + , + ) + + // Act + fireEvent.click(screen.getByText('billing.plansCommon.self')) + + // Assert + expect(handleChangeCategory).toHaveBeenCalledTimes(1) + expect(handleChangeCategory).toHaveBeenCalledWith(CategoryEnum.SELF) + }) + + it('should hide plan range switcher when category is self-hosted', () => { + // Arrange + render( + , + ) + + // Assert + expect(screen.queryByRole('switch')).not.toBeInTheDocument() + }) + }) + + // Edge case rendering behavior + describe('Edge Cases', () => { + it('should render tabs when translation strings are empty', () => { + // Arrange + mockTranslations = { + 'billing.plansCommon.cloud': '', + 'billing.plansCommon.self': '', + } + + // Act + const { container } = render( + , + ) + + // Assert + const labels = container.querySelectorAll('span') + expect(labels).toHaveLength(2) + expect(labels[0]?.textContent).toBe('') + expect(labels[1]?.textContent).toBe('') + }) + }) +}) diff --git a/web/app/components/billing/pricing/plan-switcher/plan-range-switcher.spec.tsx b/web/app/components/billing/pricing/plan-switcher/plan-range-switcher.spec.tsx new file mode 100644 index 0000000000..0b4c00603c --- /dev/null +++ b/web/app/components/billing/pricing/plan-switcher/plan-range-switcher.spec.tsx @@ -0,0 +1,81 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import * as React from 'react' +import PlanRangeSwitcher, { PlanRange } from './plan-range-switcher' + +let mockTranslations: Record = {} + +vi.mock('react-i18next', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useTranslation: () => ({ + t: (key: string) => mockTranslations[key] ?? key, + }), + } +}) + +describe('PlanRangeSwitcher', () => { + beforeEach(() => { + vi.clearAllMocks() + mockTranslations = {} + }) + + // Rendering behavior + describe('Rendering', () => { + it('should render the annual billing label', () => { + // Arrange + render() + + // Assert + expect(screen.getByText('billing.plansCommon.annualBilling')).toBeInTheDocument() + expect(screen.getByRole('switch')).toHaveAttribute('aria-checked', 'false') + }) + }) + + // Prop-driven behavior + describe('Props', () => { + it('should switch to yearly when toggled from monthly', () => { + // Arrange + const handleChange = vi.fn() + render() + + // Act + fireEvent.click(screen.getByRole('switch')) + + // Assert + expect(handleChange).toHaveBeenCalledTimes(1) + expect(handleChange).toHaveBeenCalledWith(PlanRange.yearly) + }) + + it('should switch to monthly when toggled from yearly', () => { + // Arrange + const handleChange = vi.fn() + render() + + // Act + fireEvent.click(screen.getByRole('switch')) + + // Assert + expect(handleChange).toHaveBeenCalledTimes(1) + expect(handleChange).toHaveBeenCalledWith(PlanRange.monthly) + }) + }) + + // Edge case rendering behavior + describe('Edge Cases', () => { + it('should render when the translation string is empty', () => { + // Arrange + mockTranslations = { + 'billing.plansCommon.annualBilling': '', + } + + // Act + const { container } = render() + + // Assert + const label = container.querySelector('span') + expect(label).toBeInTheDocument() + expect(label?.textContent).toBe('') + }) + }) +}) diff --git a/web/app/components/billing/pricing/plan-switcher/tab.spec.tsx b/web/app/components/billing/pricing/plan-switcher/tab.spec.tsx new file mode 100644 index 0000000000..5c335e0dd1 --- /dev/null +++ b/web/app/components/billing/pricing/plan-switcher/tab.spec.tsx @@ -0,0 +1,95 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import * as React from 'react' +import Tab from './tab' + +const Icon = ({ isActive }: { isActive: boolean }) => ( + +) + +describe('PlanSwitcherTab', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // Rendering behavior + describe('Rendering', () => { + it('should render label and icon', () => { + // Arrange + render( + , + ) + + // Assert + expect(screen.getByText('Cloud')).toBeInTheDocument() + expect(screen.getByTestId('tab-icon')).toHaveAttribute('data-active', 'false') + }) + }) + + // Prop-driven behavior + describe('Props', () => { + it('should call onClick with the provided value', () => { + // Arrange + const handleClick = vi.fn() + render( + , + ) + + // Act + fireEvent.click(screen.getByText('Self')) + + // Assert + expect(handleClick).toHaveBeenCalledTimes(1) + expect(handleClick).toHaveBeenCalledWith('self') + }) + + it('should apply active text class when isActive is true', () => { + // Arrange + render( + , + ) + + // Assert + expect(screen.getByText('Cloud')).toHaveClass('text-saas-dify-blue-accessible') + expect(screen.getByTestId('tab-icon')).toHaveAttribute('data-active', 'true') + }) + }) + + // Edge case rendering behavior + describe('Edge Cases', () => { + it('should render when label is empty', () => { + // Arrange + const { container } = render( + , + ) + + // Assert + const label = container.querySelector('span') + expect(label).toBeInTheDocument() + expect(label?.textContent).toBe('') + }) + }) +}) diff --git a/web/app/components/billing/progress-bar/index.tsx b/web/app/components/billing/progress-bar/index.tsx index 383b516b61..c41fc53310 100644 --- a/web/app/components/billing/progress-bar/index.tsx +++ b/web/app/components/billing/progress-bar/index.tsx @@ -12,6 +12,7 @@ const ProgressBar = ({ return (
Date: Wed, 24 Dec 2025 16:31:16 +0800 Subject: [PATCH 04/11] chore: fix type check for i18n (#30058) Co-authored-by: Claude Opus 4.5 Co-authored-by: yyh --- .../translate-i18n-base-on-english.yml | 12 +- .github/workflows/web-tests.yml | 3 - .../[appId]/overview/card-view.tsx | 2 +- .../overview/long-time-range-picker.tsx | 2 +- .../time-range-picker/range-selector.tsx | 2 +- .../[datasetId]/settings/page.tsx | 6 +- .../app-sidebar/dataset-info/index.tsx | 2 +- .../app-sidebar/dataset-sidebar-dropdown.tsx | 2 +- .../app/annotation/header-opts/index.spec.tsx | 7 +- .../components/app/app-publisher/index.tsx | 2 +- .../config-var/config-modal/index.tsx | 4 +- .../app/configuration/config-var/index.tsx | 2 +- .../config-var/select-type-item/index.tsx | 2 +- .../config/automatic/get-automatic-res.tsx | 4 +- .../dataset-config/settings-modal/index.tsx | 4 +- web/app/components/app/log/filter.tsx | 2 +- .../components/app/workflow-log/filter.tsx | 2 +- web/app/components/base/block-input/index.tsx | 2 +- .../base/chat/embedded-chatbot/hooks.tsx | 3 +- .../base/date-and-time-picker/hooks.ts | 4 +- .../base/encrypted-bottom/index.tsx | 4 +- .../text-to-speech/param-config-content.tsx | 4 +- .../components/base/file-uploader/hooks.ts | 4 +- .../field/input-type-select/hooks.tsx | 2 +- .../components/base/image-uploader/hooks.ts | 4 +- web/app/components/base/tag-input/index.tsx | 2 +- .../pricing/plans/cloud-plan-item/index.tsx | 4 +- .../plans/self-hosted-plan-item/button.tsx | 2 +- .../plans/self-hosted-plan-item/index.tsx | 8 +- .../self-hosted-plan-item/list/index.tsx | 4 +- .../billing/priority-label/index.tsx | 4 +- .../components/billing/upgrade-btn/index.tsx | 6 +- .../custom/custom-web-app-brand/index.tsx | 2 +- .../common/image-uploader/hooks/use-upload.ts | 4 +- .../common/retrieval-method-info/index.tsx | 4 +- .../list/template-card/content.tsx | 2 +- .../create/embedding-process/index.tsx | 2 +- .../datasets/create/file-uploader/index.tsx | 2 +- .../datasets/create/top-bar/index.tsx | 2 +- .../data-source/local-file/index.tsx | 2 +- .../embedding-process/rule-detail.tsx | 2 +- .../detail/batch-modal/csv-uploader.tsx | 2 +- .../documents/detail/embedding/index.tsx | 2 +- .../components/query-input/index.tsx | 2 +- .../datasets/list/dataset-card/index.tsx | 8 +- web/app/components/explore/category.tsx | 2 +- .../goto-anything/actions/commands/theme.tsx | 6 +- .../goto-anything/command-selector.tsx | 4 +- web/app/components/goto-anything/index.tsx | 4 +- .../account-setting/language-page/index.tsx | 3 +- .../invite-modal/role-selector.tsx | 2 +- .../members-page/operation/index.tsx | 4 +- .../presets-parameter.tsx | 2 +- .../plugins/base/deprecation-notice.tsx | 4 +- web/app/components/plugins/card/index.tsx | 7 +- web/app/components/plugins/hooks.ts | 6 +- .../plugins/marketplace/description/index.tsx | 5 +- .../components/plugins/marketplace/index.tsx | 3 +- .../plugins/marketplace/list/card-wrapper.tsx | 3 +- .../plugins/marketplace/list/index.tsx | 3 +- .../marketplace/list/list-with-collection.tsx | 3 +- .../plugins/marketplace/list/list-wrapper.tsx | 3 +- .../subscription-list/create/common-modal.tsx | 2 +- .../subscription-list/create/index.tsx | 2 +- web/app/components/plugins/types.ts | 6 +- .../hooks/use-available-nodes-meta-data.ts | 4 +- .../hooks/use-pipeline-template.ts | 2 +- .../get-schema.tsx | 2 +- .../edit-custom-collection-modal/index.tsx | 2 +- .../edit-custom-collection-modal/test-api.tsx | 2 +- web/app/components/tools/provider/empty.tsx | 6 +- .../hooks/use-available-nodes-meta-data.ts | 4 +- .../hooks/use-workflow-template.ts | 6 +- .../workflow/block-selector/blocks.tsx | 2 +- .../block-selector/featured-tools.tsx | 5 +- .../block-selector/featured-triggers.tsx | 5 +- .../workflow/block-selector/hooks.ts | 4 +- .../workflow/block-selector/start-blocks.tsx | 8 +- .../workflow/hooks/use-checklist.ts | 6 +- .../components/variable/output-var-list.tsx | 2 +- .../_base/components/variable/var-list.tsx | 2 +- .../components/operation-selector.tsx | 6 +- .../workflow/nodes/assigner/node.tsx | 2 +- .../components/condition-files-list-value.tsx | 8 +- .../condition-list/condition-item.tsx | 4 +- .../condition-list/condition-operator.tsx | 4 +- .../if-else/components/condition-value.tsx | 4 +- .../nodes/iteration/use-interactions.ts | 2 +- .../components/dataset-item.tsx | 2 +- .../condition-list/condition-operator.tsx | 4 +- .../components/filter-condition.tsx | 4 +- .../llm/components/config-prompt-item.tsx | 2 +- .../components/condition-files-list-value.tsx | 8 +- .../condition-list/condition-item.tsx | 4 +- .../condition-list/condition-operator.tsx | 4 +- .../nodes/loop/components/condition-value.tsx | 4 +- .../loop/components/loop-variables/item.tsx | 2 +- .../components/extract-parameter/update.tsx | 2 +- .../nodes/start/components/var-list.tsx | 2 +- .../workflow/nodes/start/use-config.ts | 2 +- .../components/mode-switcher.tsx | 4 +- .../nodes/trigger-webhook/use-config.ts | 4 +- .../components/node-variable-item.tsx | 2 +- .../components/var-group-item.tsx | 2 +- .../components/variable-modal.tsx | 2 +- .../panel/env-panel/variable-modal.tsx | 2 +- .../workflow/run/loop-result-panel.tsx | 2 +- .../forgot-password/ForgotPasswordForm.tsx | 2 +- web/app/install/installForm.tsx | 4 +- web/app/signin/invite-settings/page.tsx | 3 +- web/hooks/use-format-time-from-now.ts | 26 +-- web/hooks/use-knowledge.ts | 4 +- web/hooks/use-metadata.ts | 12 +- web/i18n-config/README.md | 30 +--- web/i18n-config/auto-gen-i18n.js | 6 +- web/i18n-config/check-i18n-sync.js | 134 --------------- web/i18n-config/check-i18n.js | 6 +- web/i18n-config/generate-i18n-types.js | 149 ---------------- web/i18n-config/i18next-config.ts | 128 +++++++------- web/i18n-config/index.ts | 5 +- web/i18n-config/language.ts | 66 ++++---- web/i18n-config/languages.json | 158 ----------------- web/i18n-config/languages.ts | 160 ++++++++++++++++++ web/i18n-config/server.ts | 11 +- web/i18n/en-US/app-api.ts | 1 + web/i18n/en-US/app-debug.ts | 3 + web/i18n/en-US/app.ts | 6 + web/i18n/en-US/common.ts | 6 + web/i18n/en-US/dataset.ts | 1 + web/i18n/en-US/login.ts | 1 + web/i18n/en-US/plugin-trigger.ts | 1 + web/i18n/en-US/tools.ts | 1 + web/i18n/en-US/workflow.ts | 3 + web/i18n/ja-JP/app-api.ts | 1 + web/i18n/ja-JP/app-debug.ts | 3 + web/i18n/ja-JP/app.ts | 6 + web/i18n/ja-JP/common.ts | 6 + web/i18n/ja-JP/dataset.ts | 1 + web/i18n/ja-JP/login.ts | 1 + web/i18n/ja-JP/plugin-trigger.ts | 1 + web/i18n/ja-JP/tools.ts | 1 + web/i18n/ja-JP/workflow.ts | 3 + web/i18n/zh-Hans/app-api.ts | 1 + web/i18n/zh-Hans/app-debug.ts | 3 + web/i18n/zh-Hans/app.ts | 6 + web/i18n/zh-Hans/common.ts | 6 + web/i18n/zh-Hans/dataset.ts | 1 + web/i18n/zh-Hans/login.ts | 1 + web/i18n/zh-Hans/plugin-trigger.ts | 1 + web/i18n/zh-Hans/tools.ts | 1 + web/i18n/zh-Hans/workflow.ts | 3 + web/package.json | 8 +- web/pnpm-lock.yaml | 106 +----------- web/types/i18n.d.ts | 84 +-------- web/utils/format.ts | 27 +-- 155 files changed, 560 insertions(+), 1015 deletions(-) delete mode 100644 web/i18n-config/check-i18n-sync.js delete mode 100644 web/i18n-config/generate-i18n-types.js delete mode 100644 web/i18n-config/languages.json create mode 100644 web/i18n-config/languages.ts diff --git a/.github/workflows/translate-i18n-base-on-english.yml b/.github/workflows/translate-i18n-base-on-english.yml index 8bb82d5d44..87e24a4f90 100644 --- a/.github/workflows/translate-i18n-base-on-english.yml +++ b/.github/workflows/translate-i18n-base-on-english.yml @@ -1,4 +1,4 @@ -name: Check i18n Files and Create PR +name: Translate i18n Files Based on English on: push: @@ -67,25 +67,19 @@ jobs: working-directory: ./web run: pnpm run auto-gen-i18n ${{ env.FILE_ARGS }} - - name: Generate i18n type definitions - if: env.FILES_CHANGED == 'true' - working-directory: ./web - run: pnpm run gen:i18n-types - - name: Create Pull Request if: env.FILES_CHANGED == 'true' uses: peter-evans/create-pull-request@v6 with: token: ${{ secrets.GITHUB_TOKEN }} commit-message: 'chore(i18n): update translations based on en-US changes' - title: 'chore(i18n): translate i18n files and update type definitions' + title: 'chore(i18n): translate i18n files based on en-US changes' body: | - This PR was automatically created to update i18n files and TypeScript type definitions based on changes in en-US locale. + This PR was automatically created to update i18n translation files based on changes in en-US locale. **Triggered by:** ${{ github.sha }} **Changes included:** - Updated translation files for all locales - - Regenerated TypeScript type definitions for type safety branch: chore/automated-i18n-updates-${{ github.sha }} delete-branch: true diff --git a/.github/workflows/web-tests.yml b/.github/workflows/web-tests.yml index adf52a1362..1a8925e38d 100644 --- a/.github/workflows/web-tests.yml +++ b/.github/workflows/web-tests.yml @@ -38,9 +38,6 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile - - name: Check i18n types synchronization - run: pnpm run check:i18n-types - - name: Run tests run: pnpm test:coverage 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 e9877f1715..34bb0f82f8 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/card-view.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/card-view.tsx @@ -104,7 +104,7 @@ const CardView: FC = ({ appId, isInPanel, className }) => { notify({ type, - message: t(`common.actionMsg.${message}`), + message: t(`common.actionMsg.${message}` as any) as string, }) } diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/long-time-range-picker.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/long-time-range-picker.tsx index 557b723259..55ed906a45 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/long-time-range-picker.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/long-time-range-picker.tsx @@ -53,7 +53,7 @@ const LongTimeRangePicker: FC = ({ return ( ({ value: k, name: t(`appLog.filter.period.${v.name}`) }))} + items={Object.entries(periodMapping).map(([k, v]) => ({ value: k, name: t(`appLog.filter.period.${v.name}` as any) as string }))} className="mt-0 !w-40" notClearable={true} onSelect={handleSelect} diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/time-range-picker/range-selector.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/time-range-picker/range-selector.tsx index be7181c759..88cb79ce0d 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/time-range-picker/range-selector.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/time-range-picker/range-selector.tsx @@ -66,7 +66,7 @@ const RangeSelector: FC = ({ }, []) return ( ({ ...v, name: t(`appLog.filter.period.${v.name}`) }))} + items={ranges.map(v => ({ ...v, name: t(`appLog.filter.period.${v.name}` as any) as string }))} className="mt-0 !w-40" notClearable={true} onSelect={handleSelectRange} diff --git a/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/settings/page.tsx b/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/settings/page.tsx index 9dfeaef528..aa64df3449 100644 --- a/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/settings/page.tsx +++ b/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/settings/page.tsx @@ -1,15 +1,15 @@ import * as React from 'react' import Form from '@/app/components/datasets/settings/form' -import { getLocaleOnServer, useTranslation as translate } from '@/i18n-config/server' +import { getLocaleOnServer, getTranslation } from '@/i18n-config/server' const Settings = async () => { const locale = await getLocaleOnServer() - const { t } = await translate(locale, 'dataset-settings') + const { t } = await getTranslation(locale, 'dataset-settings') return (
-
{t('title')}
+
{t('title') as any}
{t('desc')}
diff --git a/web/app/components/app-sidebar/dataset-info/index.tsx b/web/app/components/app-sidebar/dataset-info/index.tsx index ce409ff13a..39a1115062 100644 --- a/web/app/components/app-sidebar/dataset-info/index.tsx +++ b/web/app/components/app-sidebar/dataset-info/index.tsx @@ -73,7 +73,7 @@ const DatasetInfo: FC = ({ {isExternalProvider && t('dataset.externalTag')} {!isExternalProvider && isPipelinePublished && dataset.doc_form && dataset.indexing_technique && (
- {t(`dataset.chunkingMode.${DOC_FORM_TEXT[dataset.doc_form]}`)} + {t(`dataset.chunkingMode.${DOC_FORM_TEXT[dataset.doc_form]}` as any) as string} {formatIndexingTechniqueAndMethod(dataset.indexing_technique, dataset.retrieval_model_dict?.search_method)}
)} diff --git a/web/app/components/app-sidebar/dataset-sidebar-dropdown.tsx b/web/app/components/app-sidebar/dataset-sidebar-dropdown.tsx index d8e26826ca..0e55a8af65 100644 --- a/web/app/components/app-sidebar/dataset-sidebar-dropdown.tsx +++ b/web/app/components/app-sidebar/dataset-sidebar-dropdown.tsx @@ -116,7 +116,7 @@ const DatasetSidebarDropdown = ({ {isExternalProvider && t('dataset.externalTag')} {!isExternalProvider && dataset.doc_form && dataset.indexing_technique && (
- {t(`dataset.chunkingMode.${DOC_FORM_TEXT[dataset.doc_form]}`)} + {t(`dataset.chunkingMode.${DOC_FORM_TEXT[dataset.doc_form]}` as any) as string} {formatIndexingTechniqueAndMethod(dataset.indexing_technique, dataset.retrieval_model_dict?.search_method)}
)} diff --git a/web/app/components/app/annotation/header-opts/index.spec.tsx b/web/app/components/app/annotation/header-opts/index.spec.tsx index c742f8effc..c52507fb22 100644 --- a/web/app/components/app/annotation/header-opts/index.spec.tsx +++ b/web/app/components/app/annotation/header-opts/index.spec.tsx @@ -1,5 +1,6 @@ import type { ComponentProps } from 'react' import type { AnnotationItemBasic } from '../type' +import type { Locale } from '@/i18n-config' import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import * as React from 'react' @@ -166,7 +167,7 @@ type HeaderOptionsProps = ComponentProps const renderComponent = ( props: Partial = {}, - locale: string = LanguagesSupported[0] as string, + locale: Locale = LanguagesSupported[0], ) => { const defaultProps: HeaderOptionsProps = { appId: 'test-app-id', @@ -353,7 +354,7 @@ describe('HeaderOptions', () => { }) const revokeSpy = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(vi.fn()) - renderComponent({}, LanguagesSupported[1] as string) + renderComponent({}, LanguagesSupported[1]) await expandExportMenu(user) @@ -441,7 +442,7 @@ describe('HeaderOptions', () => { view.rerender( = ({ mode }) => { <>
- {t(`app.accessControlDialog.accessItems.${label}`)} + {t(`app.accessControlDialog.accessItems.${label}` as any) as string}
) 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 41f37b5895..b1d8e8cd19 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 @@ -96,7 +96,7 @@ const ConfigModal: FC = ({ if (!isValid) { Toast.notify({ type: 'error', - message: t(`appDebug.varKeyError.${errorMessageKey}`, { key: t('appDebug.variableConfig.varName') }), + message: t(`appDebug.varKeyError.${errorMessageKey}` as any, { key: t('appDebug.variableConfig.varName') }) as string, }) return false } @@ -216,7 +216,7 @@ const ConfigModal: FC = ({ if (!isValid) { Toast.notify({ type: 'error', - message: t(`appDebug.varKeyError.${errorMessageKey}`, { key: errorKey }), + message: t(`appDebug.varKeyError.${errorMessageKey}` as any, { key: errorKey }) as string, }) return } diff --git a/web/app/components/app/configuration/config-var/index.tsx b/web/app/components/app/configuration/config-var/index.tsx index 7a2a86393a..bf528f8ca6 100644 --- a/web/app/components/app/configuration/config-var/index.tsx +++ b/web/app/components/app/configuration/config-var/index.tsx @@ -98,7 +98,7 @@ const ConfigVar: FC = ({ promptVariables, readonly, onPromptVar if (errorMsgKey) { Toast.notify({ type: 'error', - message: t(errorMsgKey, { key: t(typeName) }), + message: t(errorMsgKey as any, { key: t(typeName as any) as string }) as string, }) return false } diff --git a/web/app/components/app/configuration/config-var/select-type-item/index.tsx b/web/app/components/app/configuration/config-var/select-type-item/index.tsx index ccb958977c..a72a74a5e9 100644 --- a/web/app/components/app/configuration/config-var/select-type-item/index.tsx +++ b/web/app/components/app/configuration/config-var/select-type-item/index.tsx @@ -23,7 +23,7 @@ const SelectTypeItem: FC = ({ onClick, }) => { const { t } = useTranslation() - const typeName = t(`appDebug.variableConfig.${i18nFileTypeMap[type] || type}`) + const typeName = t(`appDebug.variableConfig.${i18nFileTypeMap[type] || type}` as any) as string return (
= ({ const [editorKey, setEditorKey] = useState(`${flowId}-0`) const handleChooseTemplate = useCallback((key: string) => { return () => { - const template = t(`appDebug.generate.template.${key}.instruction`) + const template = t(`appDebug.generate.template.${key}.instruction` as any) as string setInstruction(template) setEditorKey(`${flowId}-${Date.now()}`) } @@ -322,7 +322,7 @@ const GetAutomaticRes: FC = ({ ))} 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 1454ab0c62..243aafdbdd 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 @@ -295,7 +295,7 @@ const SettingsModal: FC = ({ isExternal rowClass={rowClass} labelClass={labelClass} - t={t} + t={t as any} topK={topK} scoreThreshold={scoreThreshold} scoreThresholdEnabled={scoreThresholdEnabled} @@ -308,7 +308,7 @@ const SettingsModal: FC = ({ isExternal={false} rowClass={rowClass} labelClass={labelClass} - t={t} + t={t as any} indexMethod={indexMethod} retrievalConfig={retrievalConfig} showMultiModalTip={showMultiModalTip} diff --git a/web/app/components/app/log/filter.tsx b/web/app/components/app/log/filter.tsx index 4a0103449f..26c21e6cf6 100644 --- a/web/app/components/app/log/filter.tsx +++ b/web/app/components/app/log/filter.tsx @@ -50,7 +50,7 @@ const Filter: FC = ({ isChatMode, appId, queryParams, setQueryPara setQueryParams({ ...queryParams, period: item.value }) }} onClear={() => setQueryParams({ ...queryParams, period: '9' })} - items={Object.entries(TIME_PERIOD_MAPPING).map(([k, v]) => ({ value: k, name: t(`appLog.filter.period.${v.name}`) }))} + items={Object.entries(TIME_PERIOD_MAPPING).map(([k, v]) => ({ value: k, name: t(`appLog.filter.period.${v.name}` as any) as string }))} /> = ({ queryParams, setQueryParams }: IFilterProps) setQueryParams({ ...queryParams, period: item.value }) }} onClear={() => setQueryParams({ ...queryParams, period: '9' })} - items={Object.entries(TIME_PERIOD_MAPPING).map(([k, v]) => ({ value: k, name: t(`appLog.filter.period.${v.name}`) }))} + items={Object.entries(TIME_PERIOD_MAPPING).map(([k, v]) => ({ value: k, name: t(`appLog.filter.period.${v.name}` as any) as string }))} /> = ({ if (!isValid) { Toast.notify({ type: 'error', - message: t(`appDebug.varKeyError.${errorMessageKey}`, { key: errorKey }), + message: t(`appDebug.varKeyError.${errorMessageKey}` as any, { key: errorKey }) as string, }) return } diff --git a/web/app/components/base/chat/embedded-chatbot/hooks.tsx b/web/app/components/base/chat/embedded-chatbot/hooks.tsx index 24df08f8a8..3c7fd576a3 100644 --- a/web/app/components/base/chat/embedded-chatbot/hooks.tsx +++ b/web/app/components/base/chat/embedded-chatbot/hooks.tsx @@ -3,6 +3,7 @@ import type { ChatItem, Feedback, } from '../types' +import type { Locale } from '@/i18n-config' import type { // AppData, ConversationItem, @@ -93,7 +94,7 @@ export const useEmbeddedChatbot = () => { if (localeParam) { // If locale parameter exists in URL, use it instead of default - await changeLanguage(localeParam) + await changeLanguage(localeParam as Locale) } else if (localeFromSysVar) { // If locale is set as a system variable, use that diff --git a/web/app/components/base/date-and-time-picker/hooks.ts b/web/app/components/base/date-and-time-picker/hooks.ts index f79f28053f..ba66873cc0 100644 --- a/web/app/components/base/date-and-time-picker/hooks.ts +++ b/web/app/components/base/date-and-time-picker/hooks.ts @@ -6,7 +6,7 @@ const YEAR_RANGE = 100 export const useDaysOfWeek = () => { const { t } = useTranslation() - const daysOfWeek = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map(day => t(`time.daysInWeek.${day}`)) + const daysOfWeek = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map(day => t(`time.daysInWeek.${day}` as any) as string) return daysOfWeek } @@ -26,7 +26,7 @@ export const useMonths = () => { 'October', 'November', 'December', - ].map(month => t(`time.months.${month}`)) + ].map(month => t(`time.months.${month}` as any) as string) return months } diff --git a/web/app/components/base/encrypted-bottom/index.tsx b/web/app/components/base/encrypted-bottom/index.tsx index ff75d53db6..75b3b8151d 100644 --- a/web/app/components/base/encrypted-bottom/index.tsx +++ b/web/app/components/base/encrypted-bottom/index.tsx @@ -16,7 +16,7 @@ export const EncryptedBottom = (props: Props) => { return (
- {t(frontTextKey || 'common.provider.encrypted.front')} + {t((frontTextKey || 'common.provider.encrypted.front') as any) as string} { > PKCS1_OAEP - {t(backTextKey || 'common.provider.encrypted.back')} + {t((backTextKey || 'common.provider.encrypted.back') as any) as string}
) } diff --git a/web/app/components/base/features/new-feature-panel/text-to-speech/param-config-content.tsx b/web/app/components/base/features/new-feature-panel/text-to-speech/param-config-content.tsx index fc1052e172..ca407a69ce 100644 --- a/web/app/components/base/features/new-feature-panel/text-to-speech/param-config-content.tsx +++ b/web/app/components/base/features/new-feature-panel/text-to-speech/param-config-content.tsx @@ -97,7 +97,7 @@ const VoiceParamConfig = ({ className="h-full w-full cursor-pointer rounded-lg border-0 bg-components-input-bg-normal py-1.5 pl-3 pr-10 focus-visible:bg-state-base-hover focus-visible:outline-none group-hover:bg-state-base-hover sm:text-sm sm:leading-6" > - {languageItem?.name ? t(`common.voice.language.${languageItem?.value.replace('-', '')}`) : localLanguagePlaceholder} + {languageItem?.name ? t(`common.voice.language.${languageItem?.value.replace('-', '')}` as any) as string : localLanguagePlaceholder} - {t(`common.voice.language.${(item.value).toString().replace('-', '')}`)} + {t(`common.voice.language.${(item.value).toString().replace('-', '')}` as any) as string} {(selected || item.value === text2speech?.language) && ( { handleUpdateFile({ ...uploadingFile, uploadedId: res.id, progress: 100 }) }, onErrorCallback: (error?: any) => { - const errorMessage = getFileUploadErrorMessage(error, t('common.fileUploader.uploadFromComputerUploadError'), t) + const errorMessage = getFileUploadErrorMessage(error, t('common.fileUploader.uploadFromComputerUploadError'), t as any) notify({ type: 'error', message: errorMessage }) handleUpdateFile({ ...uploadingFile, progress: -1 }) }, @@ -287,7 +287,7 @@ export const useFile = (fileConfig: FileUpload, noNeedToCheckEnable = true) => { handleUpdateFile({ ...uploadingFile, uploadedId: res.id, progress: 100 }) }, onErrorCallback: (error?: any) => { - const errorMessage = getFileUploadErrorMessage(error, t('common.fileUploader.uploadFromComputerUploadError'), t) + const errorMessage = getFileUploadErrorMessage(error, t('common.fileUploader.uploadFromComputerUploadError'), t as any) notify({ type: 'error', message: errorMessage }) handleUpdateFile({ ...uploadingFile, progress: -1 }) }, diff --git a/web/app/components/base/form/components/field/input-type-select/hooks.tsx b/web/app/components/base/form/components/field/input-type-select/hooks.tsx index 67621fef67..eb7da8d9d0 100644 --- a/web/app/components/base/form/components/field/input-type-select/hooks.tsx +++ b/web/app/components/base/form/components/field/input-type-select/hooks.tsx @@ -44,7 +44,7 @@ export const useInputTypeOptions = (supportFile: boolean) => { return options.map((value) => { return { value, - label: t(`appDebug.variableConfig.${i18nFileTypeMap[value] || value}`), + label: t(`appDebug.variableConfig.${i18nFileTypeMap[value] || value}` as any), Icon: INPUT_TYPE_ICON[value], type: DATA_TYPE[value], } diff --git a/web/app/components/base/image-uploader/hooks.ts b/web/app/components/base/image-uploader/hooks.ts index 065a808d33..f098c378eb 100644 --- a/web/app/components/base/image-uploader/hooks.ts +++ b/web/app/components/base/image-uploader/hooks.ts @@ -82,7 +82,7 @@ export const useImageFiles = () => { setFiles(newFiles) }, onErrorCallback: (error?: any) => { - const errorMessage = getImageUploadErrorMessage(error, t('common.imageUploader.uploadFromComputerUploadError'), t) + const errorMessage = getImageUploadErrorMessage(error, t('common.imageUploader.uploadFromComputerUploadError'), t as any) notify({ type: 'error', message: errorMessage }) const newFiles = [...files.slice(0, index), { ...currentImageFile, progress: -1 }, ...files.slice(index + 1)] filesRef.current = newFiles @@ -160,7 +160,7 @@ export const useLocalFileUploader = ({ limit, disabled = false, onUpload }: useL onUpload({ ...imageFile, fileId: res.id, progress: 100 }) }, onErrorCallback: (error?: any) => { - const errorMessage = getImageUploadErrorMessage(error, t('common.imageUploader.uploadFromComputerUploadError'), t) + const errorMessage = getImageUploadErrorMessage(error, t('common.imageUploader.uploadFromComputerUploadError'), t as any) notify({ type: 'error', message: errorMessage }) onUpload({ ...imageFile, progress: -1 }) }, diff --git a/web/app/components/base/tag-input/index.tsx b/web/app/components/base/tag-input/index.tsx index 3241543565..6fe0016a1b 100644 --- a/web/app/components/base/tag-input/index.tsx +++ b/web/app/components/base/tag-input/index.tsx @@ -127,7 +127,7 @@ const TagInput: FC = ({ setValue(e.target.value) }} onKeyDown={handleKeyDown} - placeholder={t(placeholder || (isSpecialMode ? 'common.model.params.stop_sequencesPlaceholder' : 'datasetDocuments.segment.addKeyWord'))} + placeholder={t((placeholder || (isSpecialMode ? 'common.model.params.stop_sequencesPlaceholder' : 'datasetDocuments.segment.addKeyWord')) as any)} />
) diff --git a/web/app/components/billing/pricing/plans/cloud-plan-item/index.tsx b/web/app/components/billing/pricing/plans/cloud-plan-item/index.tsx index 57c85cf297..345c915c2b 100644 --- a/web/app/components/billing/pricing/plans/cloud-plan-item/index.tsx +++ b/web/app/components/billing/pricing/plans/cloud-plan-item/index.tsx @@ -106,7 +106,7 @@ const CloudPlanItem: FC = ({ {ICON_MAP[plan]}
-
{t(`${i18nPrefix}.name`)}
+
{t(`${i18nPrefix}.name` as any) as string}
{ isMostPopularPlan && (
@@ -117,7 +117,7 @@ const CloudPlanItem: FC = ({ ) }
-
{t(`${i18nPrefix}.description`)}
+
{t(`${i18nPrefix}.description` as any) as string}
{/* Price */} diff --git a/web/app/components/billing/pricing/plans/self-hosted-plan-item/button.tsx b/web/app/components/billing/pricing/plans/self-hosted-plan-item/button.tsx index 544141a6a5..73c7f31cb5 100644 --- a/web/app/components/billing/pricing/plans/self-hosted-plan-item/button.tsx +++ b/web/app/components/billing/pricing/plans/self-hosted-plan-item/button.tsx @@ -42,7 +42,7 @@ const Button = ({ onClick={handleGetPayUrl} >
- {t(`${i18nPrefix}.btnText`)} + {t(`${i18nPrefix}.btnText` as any) as string} {isPremiumPlan && ( diff --git a/web/app/components/billing/pricing/plans/self-hosted-plan-item/index.tsx b/web/app/components/billing/pricing/plans/self-hosted-plan-item/index.tsx index b89d0c6941..a1880af523 100644 --- a/web/app/components/billing/pricing/plans/self-hosted-plan-item/index.tsx +++ b/web/app/components/billing/pricing/plans/self-hosted-plan-item/index.tsx @@ -85,16 +85,16 @@ const SelfHostedPlanItem: FC = ({
{STYLE_MAP[plan].icon}
-
{t(`${i18nPrefix}.name`)}
-
{t(`${i18nPrefix}.description`)}
+
{t(`${i18nPrefix}.name` as any) as string}
+
{t(`${i18nPrefix}.description` as any) as string}
{/* Price */}
-
{t(`${i18nPrefix}.price`)}
+
{t(`${i18nPrefix}.price` as any) as string}
{!isFreePlan && ( - {t(`${i18nPrefix}.priceTip`)} + {t(`${i18nPrefix}.priceTip` as any) as string} )}
diff --git a/web/app/components/billing/pricing/plans/self-hosted-plan-item/list/index.tsx b/web/app/components/billing/pricing/plans/self-hosted-plan-item/list/index.tsx index 4ed307d36e..e7828decb9 100644 --- a/web/app/components/billing/pricing/plans/self-hosted-plan-item/list/index.tsx +++ b/web/app/components/billing/pricing/plans/self-hosted-plan-item/list/index.tsx @@ -12,13 +12,13 @@ const List = ({ }: ListProps) => { const { t } = useTranslation() const i18nPrefix = `billing.plans.${plan}` - const features = t(`${i18nPrefix}.features`, { returnObjects: true }) as string[] + const features = t(`${i18nPrefix}.features` as any, { returnObjects: true }) as unknown as string[] return (
}} />
diff --git a/web/app/components/billing/priority-label/index.tsx b/web/app/components/billing/priority-label/index.tsx index e5a6857078..b66fdc7ea9 100644 --- a/web/app/components/billing/priority-label/index.tsx +++ b/web/app/components/billing/priority-label/index.tsx @@ -31,7 +31,7 @@ const PriorityLabel = ({ className }: PriorityLabelProps) => { return ( -
{`${t('billing.plansCommon.documentProcessingPriority')}: ${t(`billing.plansCommon.priority.${priority}`)}`}
+
{`${t('billing.plansCommon.documentProcessingPriority')}: ${t(`billing.plansCommon.priority.${priority}` as any) as string}`}
{ priority !== DocumentProcessingPriority.topPriority && (
{t('billing.plansCommon.documentProcessingPriorityTip')}
@@ -51,7 +51,7 @@ const PriorityLabel = ({ className }: PriorityLabelProps) => { ) } - {t(`billing.plansCommon.priority.${priority}`)} + {t(`billing.plansCommon.priority.${priority}` as any) as string}
) diff --git a/web/app/components/billing/upgrade-btn/index.tsx b/web/app/components/billing/upgrade-btn/index.tsx index 0f23022b35..2a43090d84 100644 --- a/web/app/components/billing/upgrade-btn/index.tsx +++ b/web/app/components/billing/upgrade-btn/index.tsx @@ -46,8 +46,8 @@ const UpgradeBtn: FC = ({ } } - const defaultBadgeLabel = t(`billing.upgradeBtn.${isShort ? 'encourageShort' : 'encourage'}`) - const label = labelKey ? t(labelKey) : defaultBadgeLabel + const defaultBadgeLabel = t(`billing.upgradeBtn.${isShort ? 'encourageShort' : 'encourage'}` as any) as string + const label = labelKey ? t(labelKey as any) as string : defaultBadgeLabel if (isPlain) { return ( @@ -56,7 +56,7 @@ const UpgradeBtn: FC = ({ style={style} onClick={onClick} > - {labelKey ? label : t('billing.upgradeBtn.plain')} + {labelKey ? label : t('billing.upgradeBtn.plain' as any) as string} ) } diff --git a/web/app/components/custom/custom-web-app-brand/index.tsx b/web/app/components/custom/custom-web-app-brand/index.tsx index 3c6557dc63..6a86441bd4 100644 --- a/web/app/components/custom/custom-web-app-brand/index.tsx +++ b/web/app/components/custom/custom-web-app-brand/index.tsx @@ -68,7 +68,7 @@ const CustomWebAppBrand = () => { setFileId(res.id) }, onErrorCallback: (error?: any) => { - const errorMessage = getImageUploadErrorMessage(error, t('common.imageUploader.uploadFromComputerUploadError'), t) + const errorMessage = getImageUploadErrorMessage(error, t('common.imageUploader.uploadFromComputerUploadError'), t as any) notify({ type: 'error', message: errorMessage }) setUploadProgress(-1) }, diff --git a/web/app/components/datasets/common/image-uploader/hooks/use-upload.ts b/web/app/components/datasets/common/image-uploader/hooks/use-upload.ts index 7a0868b14c..44bde33a96 100644 --- a/web/app/components/datasets/common/image-uploader/hooks/use-upload.ts +++ b/web/app/components/datasets/common/image-uploader/hooks/use-upload.ts @@ -145,7 +145,7 @@ export const useUpload = () => { handleUpdateFile({ ...uploadingFile, uploadedId: res.id, progress: 100 }) }, onErrorCallback: (error?: any) => { - const errorMessage = getFileUploadErrorMessage(error, t('common.fileUploader.uploadFromComputerUploadError'), t) + const errorMessage = getFileUploadErrorMessage(error, t('common.fileUploader.uploadFromComputerUploadError'), t as any) Toast.notify({ type: 'error', message: errorMessage }) handleUpdateFile({ ...uploadingFile, progress: -1 }) }, @@ -187,7 +187,7 @@ export const useUpload = () => { }) }, onErrorCallback: (error?: any) => { - const errorMessage = getFileUploadErrorMessage(error, t('common.fileUploader.uploadFromComputerUploadError'), t) + const errorMessage = getFileUploadErrorMessage(error, t('common.fileUploader.uploadFromComputerUploadError'), t as any) Toast.notify({ type: 'error', message: errorMessage }) handleUpdateFile({ ...uploadingFile, progress: -1 }) }, diff --git a/web/app/components/datasets/common/retrieval-method-info/index.tsx b/web/app/components/datasets/common/retrieval-method-info/index.tsx index df8a93f666..3ab33e0278 100644 --- a/web/app/components/datasets/common/retrieval-method-info/index.tsx +++ b/web/app/components/datasets/common/retrieval-method-info/index.tsx @@ -33,8 +33,8 @@ const EconomicalRetrievalMethodConfig: FC = ({
- {t(`dataset.chunkingMode.${DOC_FORM_TEXT[chunkStructure]}`)} + {t(`dataset.chunkingMode.${DOC_FORM_TEXT[chunkStructure]}` as any) as string}
diff --git a/web/app/components/datasets/create/embedding-process/index.tsx b/web/app/components/datasets/create/embedding-process/index.tsx index 541eb62b60..d2050268aa 100644 --- a/web/app/components/datasets/create/embedding-process/index.tsx +++ b/web/app/components/datasets/create/embedding-process/index.tsx @@ -141,7 +141,7 @@ const RuleDetail: FC<{ { - const errorMessage = getFileUploadErrorMessage(e, t('datasetCreation.stepOne.uploader.failed'), t) + const errorMessage = getFileUploadErrorMessage(e, t('datasetCreation.stepOne.uploader.failed'), t as any) notify({ type: 'error', message: errorMessage }) onFileUpdate(fileItem, -2, fileListRef.current) return Promise.resolve({ ...fileItem }) diff --git a/web/app/components/datasets/create/top-bar/index.tsx b/web/app/components/datasets/create/top-bar/index.tsx index 3f30d9a8da..632ad3ab73 100644 --- a/web/app/components/datasets/create/top-bar/index.tsx +++ b/web/app/components/datasets/create/top-bar/index.tsx @@ -39,7 +39,7 @@ export const TopBar: FC = (props) => {
({ - name: t(STEP_T_MAP[i + 1]), + name: t(STEP_T_MAP[i + 1] as any) as string, }))} {...rest} /> diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/index.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/index.tsx index 31570ef4cf..c36155e104 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/index.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/index.tsx @@ -155,7 +155,7 @@ const LocalFile = ({ return Promise.resolve({ ...completeFile }) }) .catch((e) => { - const errorMessage = getFileUploadErrorMessage(e, t('datasetCreation.stepOne.uploader.failed'), t) + const errorMessage = getFileUploadErrorMessage(e, t('datasetCreation.stepOne.uploader.failed'), t as any) notify({ type: 'error', message: errorMessage }) updateFile(fileItem, -2, fileListRef.current) return Promise.resolve({ ...fileItem }) diff --git a/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/rule-detail.tsx b/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/rule-detail.tsx index a16e284bcf..55590636a6 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/rule-detail.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/rule-detail.tsx @@ -63,7 +63,7 @@ const RuleDetail = ({ /> = ({ return Promise.resolve({ ...completeFile }) }) .catch((e) => { - const errorMessage = getFileUploadErrorMessage(e, t('datasetCreation.stepOne.uploader.failed'), t) + const errorMessage = getFileUploadErrorMessage(e, t('datasetCreation.stepOne.uploader.failed'), t as any) notify({ type: 'error', message: errorMessage }) const errorFile = { ...fileItem, diff --git a/web/app/components/datasets/documents/detail/embedding/index.tsx b/web/app/components/datasets/documents/detail/embedding/index.tsx index db83d89c40..2978ec5681 100644 --- a/web/app/components/datasets/documents/detail/embedding/index.tsx +++ b/web/app/components/datasets/documents/detail/embedding/index.tsx @@ -133,7 +133,7 @@ const RuleDetail: FC = React.memo(({ /> {icon} -
{t(`dataset.retrieval.${retrievalMethod}.title`)}
+
{t(`dataset.retrieval.${retrievalMethod}.title` as any) as string}
)} diff --git a/web/app/components/datasets/list/dataset-card/index.tsx b/web/app/components/datasets/list/dataset-card/index.tsx index 8087b80fda..e72349db3a 100644 --- a/web/app/components/datasets/list/dataset-card/index.tsx +++ b/web/app/components/datasets/list/dataset-card/index.tsx @@ -217,17 +217,17 @@ const DatasetCard = ({ {dataset.doc_form && ( - {t(`dataset.chunkingMode.${DOC_FORM_TEXT[dataset.doc_form]}`)} + {t(`dataset.chunkingMode.${DOC_FORM_TEXT[dataset.doc_form]}` as any) as string} )} {dataset.indexing_technique && ( - {formatIndexingTechniqueAndMethod(dataset.indexing_technique, dataset.retrieval_model_dict?.search_method)} + {formatIndexingTechniqueAndMethod(dataset.indexing_technique, dataset.retrieval_model_dict?.search_method) as any} )} {dataset.is_multimodal && ( diff --git a/web/app/components/explore/category.tsx b/web/app/components/explore/category.tsx index eba883d849..593ed8d938 100644 --- a/web/app/components/explore/category.tsx +++ b/web/app/components/explore/category.tsx @@ -50,7 +50,7 @@ const Category: FC = ({ className={itemClassName(name === value)} onClick={() => onChange(name)} > - {(categoryI18n as any)[name] ? t(`explore.category.${name}`) : name} + {(categoryI18n as any)[name] ? t(`explore.category.${name}` as any) as string : name}
))}
diff --git a/web/app/components/goto-anything/actions/commands/theme.tsx b/web/app/components/goto-anything/actions/commands/theme.tsx index dc8ca46bc0..34df84e33b 100644 --- a/web/app/components/goto-anything/actions/commands/theme.tsx +++ b/web/app/components/goto-anything/actions/commands/theme.tsx @@ -36,13 +36,13 @@ const buildThemeCommands = (query: string, locale?: string): CommandSearchResult const q = query.toLowerCase() const list = THEME_ITEMS.filter(item => !q - || i18n.t(item.titleKey, { lng: locale }).toLowerCase().includes(q) + || i18n.t(item.titleKey as any, { lng: locale }).toLowerCase().includes(q) || item.id.includes(q), ) return list.map(item => ({ id: item.id, - title: i18n.t(item.titleKey, { lng: locale }), - description: i18n.t(item.descKey, { lng: locale }), + title: i18n.t(item.titleKey as any, { lng: locale }), + description: i18n.t(item.descKey as any, { lng: locale }), type: 'command' as const, icon: (
diff --git a/web/app/components/goto-anything/command-selector.tsx b/web/app/components/goto-anything/command-selector.tsx index 0d89669ec8..f62a7a3829 100644 --- a/web/app/components/goto-anything/command-selector.tsx +++ b/web/app/components/goto-anything/command-selector.tsx @@ -117,7 +117,7 @@ const CommandSelector: FC = ({ actions, onCommandSelect, searchFilter, co '/community': 'app.gotoAnything.actions.communityDesc', '/zen': 'app.gotoAnything.actions.zenDesc', } - return t(slashKeyMap[item.key] || item.description) + return t((slashKeyMap[item.key] || item.description) as any) })() ) : ( @@ -128,7 +128,7 @@ const CommandSelector: FC = ({ actions, onCommandSelect, searchFilter, co '@knowledge': 'app.gotoAnything.actions.searchKnowledgeBasesDesc', '@node': 'app.gotoAnything.actions.searchWorkflowNodesDesc', } - return t(keyMap[item.key]) + return t(keyMap[item.key] as any) as string })() )} diff --git a/web/app/components/goto-anything/index.tsx b/web/app/components/goto-anything/index.tsx index 1c61cb9516..76c2e26ebd 100644 --- a/web/app/components/goto-anything/index.tsx +++ b/web/app/components/goto-anything/index.tsx @@ -243,7 +243,7 @@ const GotoAnything: FC = ({ knowledge: 'app.gotoAnything.emptyState.noKnowledgeBasesFound', node: 'app.gotoAnything.emptyState.noWorkflowNodesFound', } - return t(keyMap[commandType] || 'app.gotoAnything.noResults') + return t((keyMap[commandType] || 'app.gotoAnything.noResults') as any) })() : t('app.gotoAnything.noResults')}
@@ -410,7 +410,7 @@ const GotoAnything: FC = ({ 'workflow-node': 'app.gotoAnything.groups.workflowNodes', 'command': 'app.gotoAnything.groups.commands', } - return t(typeMap[type] || `${type}s`) + return t((typeMap[type] || `${type}s`) as any) })()} className="p-2 capitalize text-text-secondary" > diff --git a/web/app/components/header/account-setting/language-page/index.tsx b/web/app/components/header/account-setting/language-page/index.tsx index 00db4dbbaf..0568cb1ec9 100644 --- a/web/app/components/header/account-setting/language-page/index.tsx +++ b/web/app/components/header/account-setting/language-page/index.tsx @@ -1,6 +1,7 @@ 'use client' import type { Item } from '@/app/components/base/select' +import type { Locale } from '@/i18n-config' import { useState } from 'react' import { useTranslation } from 'react-i18next' import { useContext } from 'use-context-selector' @@ -32,7 +33,7 @@ export default function LanguagePage() { await updateUserProfile({ url, body: { [bodyKey]: item.value } }) notify({ type: 'success', message: t('common.actionMsg.modifiedSuccessfully') }) - setLocaleOnClient(item.value.toString()) + setLocaleOnClient(item.value.toString() as Locale) } catch (e) { notify({ type: 'error', message: (e as Error).message }) diff --git a/web/app/components/header/account-setting/members-page/invite-modal/role-selector.tsx b/web/app/components/header/account-setting/members-page/invite-modal/role-selector.tsx index 7d8169e4c4..07f89df24d 100644 --- a/web/app/components/header/account-setting/members-page/invite-modal/role-selector.tsx +++ b/web/app/components/header/account-setting/members-page/invite-modal/role-selector.tsx @@ -36,7 +36,7 @@ const RoleSelector = ({ value, onChange }: RoleSelectorProps) => { className="block" >
-
{t('common.members.invitedAsRole', { role: t(`common.members.${toHump(value)}`) })}
+
{t('common.members.invitedAsRole', { role: t(`common.members.${toHump(value)}` as any) })}
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 2b3c9e350a..da61746685 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 @@ -106,8 +106,8 @@ const Operation = ({ :
}
-
{t(`common.members.${toHump(role)}`)}
-
{t(`common.members.${toHump(role)}Tip`)}
+
{t(`common.members.${toHump(role)}` as any)}
+
{t(`common.members.${toHump(role)}Tip` as any)}
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 d53467028c..9e8637ce49 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 @@ -44,7 +44,7 @@ const PresetsParameter: FC = ({ text: (
{getToneIcon(tone.id)} - {t(`common.model.tone.${tone.name}`) as string} + {t(`common.model.tone.${tone.name}` as any) as string}
), } diff --git a/web/app/components/plugins/base/deprecation-notice.tsx b/web/app/components/plugins/base/deprecation-notice.tsx index 8832c77961..76d64a45f4 100644 --- a/web/app/components/plugins/base/deprecation-notice.tsx +++ b/web/app/components/plugins/base/deprecation-notice.tsx @@ -82,7 +82,7 @@ const DeprecationNotice: FC = ({ ), }} values={{ - deprecatedReason: t(`${i18nPrefix}.reason.${deprecatedReasonKey}`), + deprecatedReason: t(`${i18nPrefix}.reason.${deprecatedReasonKey}` as any) as string, alternativePluginId, }} /> @@ -91,7 +91,7 @@ const DeprecationNotice: FC = ({ { hasValidDeprecatedReason && !alternativePluginId && ( - {t(`${i18nPrefix}.onlyReason`, { deprecatedReason: t(`${i18nPrefix}.reason.${deprecatedReasonKey}`) })} + {t(`${i18nPrefix}.onlyReason` as any, { deprecatedReason: t(`${i18nPrefix}.reason.${deprecatedReasonKey}` as any) as string }) as string} ) } diff --git a/web/app/components/plugins/card/index.tsx b/web/app/components/plugins/card/index.tsx index 1cb15bf70b..f063a8d572 100644 --- a/web/app/components/plugins/card/index.tsx +++ b/web/app/components/plugins/card/index.tsx @@ -1,11 +1,14 @@ 'use client' import type { Plugin } from '../types' +import type { Locale } from '@/i18n-config' import { RiAlertFill } from '@remixicon/react' import * as React from 'react' import { useMixedTranslation } from '@/app/components/plugins/marketplace/hooks' import { useGetLanguage } from '@/context/i18n' import useTheme from '@/hooks/use-theme' -import { renderI18nObject } from '@/i18n-config' +import { + renderI18nObject, +} from '@/i18n-config' import { getLanguage } from '@/i18n-config/language' import { Theme } from '@/types/app' import { cn } from '@/utils/classnames' @@ -30,7 +33,7 @@ export type Props = { footer?: React.ReactNode isLoading?: boolean loadingFileName?: string - locale?: string + locale?: Locale limitedInstall?: boolean } diff --git a/web/app/components/plugins/hooks.ts b/web/app/components/plugins/hooks.ts index 8303a4cc46..423ce1023c 100644 --- a/web/app/components/plugins/hooks.ts +++ b/web/app/components/plugins/hooks.ts @@ -20,7 +20,7 @@ export const useTags = (translateFromOut?: TFunction) => { return tagKeys.map((tag) => { return { name: tag, - label: t(`pluginTags.tags.${tag}`), + label: t(`pluginTags.tags.${tag}` as any) as string, } }) }, [t]) @@ -66,14 +66,14 @@ export const useCategories = (translateFromOut?: TFunction, isSingle?: boolean) } return { name: category, - label: isSingle ? t(`plugin.categorySingle.${category}`) : t(`plugin.category.${category}s`), + label: isSingle ? t(`plugin.categorySingle.${category}` as any) as string : t(`plugin.category.${category}s` as any) as string, } }) }, [t, isSingle]) const categoriesMap = useMemo(() => { return categories.reduce((acc, category) => { - acc[category.name] = category + acc[category.name] = category as any return acc }, {} as Record) }, [categories]) diff --git a/web/app/components/plugins/marketplace/description/index.tsx b/web/app/components/plugins/marketplace/description/index.tsx index 5b1ac6bb09..66a6368c08 100644 --- a/web/app/components/plugins/marketplace/description/index.tsx +++ b/web/app/components/plugins/marketplace/description/index.tsx @@ -1,10 +1,11 @@ +import type { Locale } from '@/i18n-config' import { getLocaleOnServer, - useTranslation as translate, + getTranslation as translate, } from '@/i18n-config/server' type DescriptionProps = { - locale?: string + locale?: Locale } const Description = async ({ locale: localeFromProps, diff --git a/web/app/components/plugins/marketplace/index.tsx b/web/app/components/plugins/marketplace/index.tsx index 47acb840e4..ff9a4d60bc 100644 --- a/web/app/components/plugins/marketplace/index.tsx +++ b/web/app/components/plugins/marketplace/index.tsx @@ -1,5 +1,6 @@ import type { MarketplaceCollection, SearchParams } from './types' import type { Plugin } from '@/app/components/plugins/types' +import type { Locale } from '@/i18n-config' import { TanstackQueryInitializer } from '@/context/query-client' import { MarketplaceContextProvider } from './context' import Description from './description' @@ -8,7 +9,7 @@ import StickySearchAndSwitchWrapper from './sticky-search-and-switch-wrapper' import { getMarketplaceCollectionsAndPlugins } from './utils' type MarketplaceProps = { - locale: string + locale: Locale showInstallButton?: boolean shouldExclude?: boolean searchParams?: SearchParams diff --git a/web/app/components/plugins/marketplace/list/card-wrapper.tsx b/web/app/components/plugins/marketplace/list/card-wrapper.tsx index 159107eb97..ddc505b0d8 100644 --- a/web/app/components/plugins/marketplace/list/card-wrapper.tsx +++ b/web/app/components/plugins/marketplace/list/card-wrapper.tsx @@ -1,5 +1,6 @@ 'use client' import type { Plugin } from '@/app/components/plugins/types' +import type { Locale } from '@/i18n-config' import { RiArrowRightUpLine } from '@remixicon/react' import { useBoolean } from 'ahooks' import { useTheme } from 'next-themes' @@ -17,7 +18,7 @@ import { getPluginDetailLinkInMarketplace, getPluginLinkInMarketplace } from '.. type CardWrapperProps = { plugin: Plugin showInstallButton?: boolean - locale?: string + locale?: Locale } const CardWrapperComponent = ({ plugin, diff --git a/web/app/components/plugins/marketplace/list/index.tsx b/web/app/components/plugins/marketplace/list/index.tsx index 95f7cb37a8..54889b232f 100644 --- a/web/app/components/plugins/marketplace/list/index.tsx +++ b/web/app/components/plugins/marketplace/list/index.tsx @@ -1,6 +1,7 @@ 'use client' import type { Plugin } from '../../types' import type { MarketplaceCollection } from '../types' +import type { Locale } from '@/i18n-config' import { cn } from '@/utils/classnames' import Empty from '../empty' import CardWrapper from './card-wrapper' @@ -11,7 +12,7 @@ type ListProps = { marketplaceCollectionPluginsMap: Record plugins?: Plugin[] showInstallButton?: boolean - locale: string + locale: Locale cardContainerClassName?: string cardRender?: (plugin: Plugin) => React.JSX.Element | null onMoreClick?: () => void diff --git a/web/app/components/plugins/marketplace/list/list-with-collection.tsx b/web/app/components/plugins/marketplace/list/list-with-collection.tsx index c401fbe3b9..2d246efb82 100644 --- a/web/app/components/plugins/marketplace/list/list-with-collection.tsx +++ b/web/app/components/plugins/marketplace/list/list-with-collection.tsx @@ -3,6 +3,7 @@ import type { MarketplaceCollection } from '../types' import type { SearchParamsFromCollection } from '@/app/components/plugins/marketplace/types' import type { Plugin } from '@/app/components/plugins/types' +import type { Locale } from '@/i18n-config' import { RiArrowRightSLine } from '@remixicon/react' import { useMixedTranslation } from '@/app/components/plugins/marketplace/hooks' import { getLanguage } from '@/i18n-config/language' @@ -13,7 +14,7 @@ type ListWithCollectionProps = { marketplaceCollections: MarketplaceCollection[] marketplaceCollectionPluginsMap: Record showInstallButton?: boolean - locale: string + locale: Locale cardContainerClassName?: string cardRender?: (plugin: Plugin) => React.JSX.Element | null onMoreClick?: (searchParams?: SearchParamsFromCollection) => void diff --git a/web/app/components/plugins/marketplace/list/list-wrapper.tsx b/web/app/components/plugins/marketplace/list/list-wrapper.tsx index f2fbd085f0..650c8a7447 100644 --- a/web/app/components/plugins/marketplace/list/list-wrapper.tsx +++ b/web/app/components/plugins/marketplace/list/list-wrapper.tsx @@ -1,6 +1,7 @@ 'use client' import type { Plugin } from '../../types' import type { MarketplaceCollection } from '../types' +import type { Locale } from '@/i18n-config' import { useEffect } from 'react' import Loading from '@/app/components/base/loading' import { useMixedTranslation } from '@/app/components/plugins/marketplace/hooks' @@ -12,7 +13,7 @@ type ListWrapperProps = { marketplaceCollections: MarketplaceCollection[] marketplaceCollectionPluginsMap: Record showInstallButton?: boolean - locale: string + locale: Locale } const ListWrapper = ({ marketplaceCollections, diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/common-modal.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/common-modal.tsx index 16a789e67b..31e0bd6a85 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/common-modal.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/common-modal.tsx @@ -330,7 +330,7 @@ export const CommonCreateModal = ({ onClose, createType, builder }: Props) => { return ( = MAX_COUNT ? t('pluginTrigger.subscription.maxCount', { num: MAX_COUNT }) : t(`pluginTrigger.subscription.addType.options.${methodType.toLowerCase()}.description`)} + popupContent={subscriptionCount >= MAX_COUNT ? t('pluginTrigger.subscription.maxCount', { num: MAX_COUNT }) : t(`pluginTrigger.subscription.addType.options.${methodType.toLowerCase()}.description` as any)} disabled={!(supportedMethods?.length === 1 || subscriptionCount >= MAX_COUNT)} > - brief: Record - description: Record + label: Partial> + brief: Partial> + description: Partial> // Repo readme.md content introduction: string repository: string diff --git a/web/app/components/rag-pipeline/hooks/use-available-nodes-meta-data.ts b/web/app/components/rag-pipeline/hooks/use-available-nodes-meta-data.ts index 6464534c83..45c36196f5 100644 --- a/web/app/components/rag-pipeline/hooks/use-available-nodes-meta-data.ts +++ b/web/app/components/rag-pipeline/hooks/use-available-nodes-meta-data.ts @@ -36,8 +36,8 @@ export const useAvailableNodesMetaData = () => { const availableNodesMetaData = useMemo(() => mergedNodesMetaData.map((node) => { const { metaData } = node - const title = t(`workflow.blocks.${metaData.type}`) - const description = t(`workflow.blocksAbout.${metaData.type}`) + const title = t(`workflow.blocks.${metaData.type}` as any) as string + const description = t(`workflow.blocksAbout.${metaData.type}` as any) as string return { ...node, metaData: { diff --git a/web/app/components/rag-pipeline/hooks/use-pipeline-template.ts b/web/app/components/rag-pipeline/hooks/use-pipeline-template.ts index 5955ee5c45..fc64e6c8c7 100644 --- a/web/app/components/rag-pipeline/hooks/use-pipeline-template.ts +++ b/web/app/components/rag-pipeline/hooks/use-pipeline-template.ts @@ -14,7 +14,7 @@ export const usePipelineTemplate = () => { data: { ...knowledgeBaseDefault.defaultValue as KnowledgeBaseNodeType, type: knowledgeBaseDefault.metaData.type, - title: t(`workflow.blocks.${knowledgeBaseDefault.metaData.type}`), + title: t(`workflow.blocks.${knowledgeBaseDefault.metaData.type}` as any) as string, selected: true, }, position: { diff --git a/web/app/components/tools/edit-custom-collection-modal/get-schema.tsx b/web/app/components/tools/edit-custom-collection-modal/get-schema.tsx index 4ecee282f9..5c85aee32c 100644 --- a/web/app/components/tools/edit-custom-collection-modal/get-schema.tsx +++ b/web/app/components/tools/edit-custom-collection-modal/get-schema.tsx @@ -111,7 +111,7 @@ const GetSchema: FC = ({ }} className="system-sm-regular cursor-pointer whitespace-nowrap rounded-lg px-3 py-1.5 leading-5 text-text-secondary hover:bg-components-panel-on-panel-item-bg-hover" > - {t(`tools.createTool.exampleOptions.${item.key}`)} + {t(`tools.createTool.exampleOptions.${item.key}` as any) as string}
))}
diff --git a/web/app/components/tools/edit-custom-collection-modal/index.tsx b/web/app/components/tools/edit-custom-collection-modal/index.tsx index 93ef9142d9..474c262010 100644 --- a/web/app/components/tools/edit-custom-collection-modal/index.tsx +++ b/web/app/components/tools/edit-custom-collection-modal/index.tsx @@ -292,7 +292,7 @@ const EditCustomCollectionModal: FC = ({
{t('tools.createTool.authMethod.title')}
setCredentialsModalShow(true)}> -
{t(`tools.createTool.authMethod.types.${credential.auth_type}`)}
+
{t(`tools.createTool.authMethod.types.${credential.auth_type}` as any) as string}
diff --git a/web/app/components/tools/edit-custom-collection-modal/test-api.tsx b/web/app/components/tools/edit-custom-collection-modal/test-api.tsx index 8aacb7ad07..30ead4425b 100644 --- a/web/app/components/tools/edit-custom-collection-modal/test-api.tsx +++ b/web/app/components/tools/edit-custom-collection-modal/test-api.tsx @@ -78,7 +78,7 @@ const TestApi: FC = ({
{t('tools.createTool.authMethod.title')}
setCredentialsModalShow(true)}> -
{t(`tools.createTool.authMethod.types.${tempCredential.auth_type}`)}
+
{t(`tools.createTool.authMethod.types.${tempCredential.auth_type}` as any) as string}
diff --git a/web/app/components/tools/provider/empty.tsx b/web/app/components/tools/provider/empty.tsx index 7e916ba62f..e79607751e 100644 --- a/web/app/components/tools/provider/empty.tsx +++ b/web/app/components/tools/provider/empty.tsx @@ -33,17 +33,17 @@ const Empty = ({ const Comp = (hasLink ? Link : 'div') as any const linkProps = hasLink ? { href: getLink(type), target: '_blank' } : {} const renderType = isAgent ? 'agent' : type - const hasTitle = t(`tools.addToolModal.${renderType}.title`) !== `tools.addToolModal.${renderType}.title` + const hasTitle = t(`tools.addToolModal.${renderType}.title` as any) as string !== `tools.addToolModal.${renderType}.title` return (
- {hasTitle ? t(`tools.addToolModal.${renderType}.title`) : 'No tools available'} + {hasTitle ? t(`tools.addToolModal.${renderType}.title` as any) as string : 'No tools available'}
{(!isAgent && hasTitle) && ( - {t(`tools.addToolModal.${renderType}.tip`)} + {t(`tools.addToolModal.${renderType}.tip` as any) as string} {' '} {hasLink && } diff --git a/web/app/components/workflow-app/hooks/use-available-nodes-meta-data.ts b/web/app/components/workflow-app/hooks/use-available-nodes-meta-data.ts index 24d862321d..7f08612234 100644 --- a/web/app/components/workflow-app/hooks/use-available-nodes-meta-data.ts +++ b/web/app/components/workflow-app/hooks/use-available-nodes-meta-data.ts @@ -42,8 +42,8 @@ export const useAvailableNodesMetaData = () => { const availableNodesMetaData = useMemo(() => mergedNodesMetaData.map((node) => { const { metaData } = node - const title = t(`workflow.blocks.${metaData.type}`) - const description = t(`workflow.blocksAbout.${metaData.type}`) + const title = t(`workflow.blocks.${metaData.type}` as any) as string + const description = t(`workflow.blocksAbout.${metaData.type}` as any) as string const helpLinkPath = `guides/workflow/node/${metaData.helpLinkUri}` return { ...node, diff --git a/web/app/components/workflow-app/hooks/use-workflow-template.ts b/web/app/components/workflow-app/hooks/use-workflow-template.ts index b48a68e1eb..46f2c3e0a3 100644 --- a/web/app/components/workflow-app/hooks/use-workflow-template.ts +++ b/web/app/components/workflow-app/hooks/use-workflow-template.ts @@ -18,7 +18,7 @@ export const useWorkflowTemplate = () => { data: { ...startDefault.defaultValue as StartNodeType, type: startDefault.metaData.type, - title: t(`workflow.blocks.${startDefault.metaData.type}`), + title: t(`workflow.blocks.${startDefault.metaData.type}` as any) as string, }, position: START_INITIAL_POSITION, }) @@ -34,7 +34,7 @@ export const useWorkflowTemplate = () => { }, selected: true, type: llmDefault.metaData.type, - title: t(`workflow.blocks.${llmDefault.metaData.type}`), + title: t(`workflow.blocks.${llmDefault.metaData.type}` as any) as string, }, position: { x: START_INITIAL_POSITION.x + NODE_WIDTH_X_OFFSET, @@ -48,7 +48,7 @@ export const useWorkflowTemplate = () => { ...answerDefault.defaultValue, answer: `{{#${llmNode.id}.text#}}`, type: answerDefault.metaData.type, - title: t(`workflow.blocks.${answerDefault.metaData.type}`), + title: t(`workflow.blocks.${answerDefault.metaData.type}` as any) as string, }, position: { x: START_INITIAL_POSITION.x + NODE_WIDTH_X_OFFSET * 2, diff --git a/web/app/components/workflow/block-selector/blocks.tsx b/web/app/components/workflow/block-selector/blocks.tsx index e626073a3a..51d08d15df 100644 --- a/web/app/components/workflow/block-selector/blocks.tsx +++ b/web/app/components/workflow/block-selector/blocks.tsx @@ -85,7 +85,7 @@ const Blocks = ({ { classification !== '-' && !!filteredList.length && (
- {t(`workflow.tabs.${classification}`)} + {t(`workflow.tabs.${classification}` as any) as string}
) } diff --git a/web/app/components/workflow/block-selector/featured-tools.tsx b/web/app/components/workflow/block-selector/featured-tools.tsx index 1e6b976739..343d56bd1a 100644 --- a/web/app/components/workflow/block-selector/featured-tools.tsx +++ b/web/app/components/workflow/block-selector/featured-tools.tsx @@ -2,6 +2,7 @@ import type { ToolWithProvider } from '../types' import type { ToolDefaultValue, ToolValue } from './types' import type { Plugin } from '@/app/components/plugins/types' +import type { Locale } from '@/i18n-config' import { RiMoreLine } from '@remixicon/react' import Link from 'next/link' import { useEffect, useMemo, useState } from 'react' @@ -178,7 +179,7 @@ const FeaturedTools = ({ onInstallSuccess={async () => { await onInstallSuccess?.() }} - t={t} + t={t as any} /> ))}
@@ -221,7 +222,7 @@ const FeaturedTools = ({ type FeaturedToolUninstalledItemProps = { plugin: Plugin - language: string + language: Locale onInstallSuccess?: () => Promise | void t: (key: string, options?: Record) => string } diff --git a/web/app/components/workflow/block-selector/featured-triggers.tsx b/web/app/components/workflow/block-selector/featured-triggers.tsx index c986a8abc0..66705a9d06 100644 --- a/web/app/components/workflow/block-selector/featured-triggers.tsx +++ b/web/app/components/workflow/block-selector/featured-triggers.tsx @@ -1,6 +1,7 @@ 'use client' import type { TriggerDefaultValue, TriggerWithProvider } from './types' import type { Plugin } from '@/app/components/plugins/types' +import type { Locale } from '@/i18n-config' import { RiMoreLine } from '@remixicon/react' import Link from 'next/link' import { useEffect, useMemo, useState } from 'react' @@ -170,7 +171,7 @@ const FeaturedTriggers = ({ onInstallSuccess={async () => { await onInstallSuccess?.() }} - t={t} + t={t as any} /> ))}
@@ -213,7 +214,7 @@ const FeaturedTriggers = ({ type FeaturedTriggerUninstalledItemProps = { plugin: Plugin - language: string + language: Locale onInstallSuccess?: () => Promise | void t: (key: string, options?: Record) => string } diff --git a/web/app/components/workflow/block-selector/hooks.ts b/web/app/components/workflow/block-selector/hooks.ts index 075a0b7d38..462d58df9f 100644 --- a/web/app/components/workflow/block-selector/hooks.ts +++ b/web/app/components/workflow/block-selector/hooks.ts @@ -17,7 +17,7 @@ export const useBlocks = () => { return BLOCKS.map((block) => { return { ...block, - title: t(`workflow.blocks.${block.type}`), + title: t(`workflow.blocks.${block.type}` as any) as string, } }) } @@ -28,7 +28,7 @@ export const useStartBlocks = () => { return START_BLOCKS.map((block) => { return { ...block, - title: t(`workflow.blocks.${block.type}`), + title: t(`workflow.blocks.${block.type}` as any) as string, } }) } diff --git a/web/app/components/workflow/block-selector/start-blocks.tsx b/web/app/components/workflow/block-selector/start-blocks.tsx index 5c4311b805..b6aaef84f9 100644 --- a/web/app/components/workflow/block-selector/start-blocks.tsx +++ b/web/app/components/workflow/block-selector/start-blocks.tsx @@ -43,7 +43,7 @@ const StartBlocks = ({ if (blockType === BlockEnumValues.TriggerWebhook) return t('workflow.customWebhook') - return t(`workflow.blocks.${blockType}`) + return t(`workflow.blocks.${blockType}` as any) as string } return START_BLOCKS.filter((block) => { @@ -83,10 +83,10 @@ const StartBlocks = ({
{block.type === BlockEnumValues.TriggerWebhook ? t('workflow.customWebhook') - : t(`workflow.blocks.${block.type}`)} + : t(`workflow.blocks.${block.type}` as any) as string}
- {t(`workflow.blocksAbout.${block.type}`)} + {t(`workflow.blocksAbout.${block.type}` as any) as string}
{(block.type === BlockEnumValues.TriggerWebhook || block.type === BlockEnumValues.TriggerSchedule) && (
@@ -107,7 +107,7 @@ const StartBlocks = ({ type={block.type} />
- {t(`workflow.blocks.${block.type}`)} + {t(`workflow.blocks.${block.type}` as any) as string} {block.type === BlockEnumValues.Start && ( {t('workflow.blocks.originalStartNode')} )} diff --git a/web/app/components/workflow/hooks/use-checklist.ts b/web/app/components/workflow/hooks/use-checklist.ts index 6e49bfa0be..7cead40705 100644 --- a/web/app/components/workflow/hooks/use-checklist.ts +++ b/web/app/components/workflow/hooks/use-checklist.ts @@ -237,8 +237,8 @@ export const useChecklist = (nodes: Node[], edges: Edge[]) => { list.push({ id: `${type}-need-added`, type, - title: t(`workflow.blocks.${type}`), - errorMessage: t('workflow.common.needAdd', { node: t(`workflow.blocks.${type}`) }), + title: t(`workflow.blocks.${type}` as any) as string, + errorMessage: t('workflow.common.needAdd', { node: t(`workflow.blocks.${type}` as any) as string }), canNavigate: false, }) } @@ -409,7 +409,7 @@ export const useChecklistBeforePublish = () => { const type = isRequiredNodesType[i] if (!filteredNodes.find(node => node.data.type === type)) { - notify({ type: 'error', message: t('workflow.common.needAdd', { node: t(`workflow.blocks.${type}`) }) }) + notify({ type: 'error', message: t('workflow.common.needAdd', { node: t(`workflow.blocks.${type}` as any) as string }) }) return false } } diff --git a/web/app/components/workflow/nodes/_base/components/variable/output-var-list.tsx b/web/app/components/workflow/nodes/_base/components/variable/output-var-list.tsx index 44df18ddf2..91f89b147e 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/output-var-list.tsx +++ b/web/app/components/workflow/nodes/_base/components/variable/output-var-list.tsx @@ -44,7 +44,7 @@ const OutputVarList: FC = ({ if (!isValid) { setToastHandler(Toast.notify({ type: 'error', - message: t(`appDebug.varKeyError.${errorMessageKey}`, { key: errorKey }), + message: t(`appDebug.varKeyError.${errorMessageKey}` as any, { key: errorKey }), })) return } diff --git a/web/app/components/workflow/nodes/_base/components/variable/var-list.tsx b/web/app/components/workflow/nodes/_base/components/variable/var-list.tsx index 2d96baaf28..7b548c6afc 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/var-list.tsx +++ b/web/app/components/workflow/nodes/_base/components/variable/var-list.tsx @@ -57,7 +57,7 @@ const VarList: FC = ({ if (!isValid) { setToastHandle(Toast.notify({ type: 'error', - message: t(`appDebug.varKeyError.${errorMessageKey}`, { key: errorKey }), + message: t(`appDebug.varKeyError.${errorMessageKey}` as any, { key: errorKey }), })) return } 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 fd346e632d..0cc905ae06 100644 --- a/web/app/components/workflow/nodes/assigner/components/operation-selector.tsx +++ b/web/app/components/workflow/nodes/assigner/components/operation-selector.tsx @@ -72,7 +72,7 @@ const OperationSelector: FC = ({ className={`system-sm-regular overflow-hidden truncate text-ellipsis ${selectedItem ? 'text-components-input-text-filled' : 'text-components-input-text-disabled'}`} > - {selectedItem?.name ? t(`${i18nPrefix}.operations.${selectedItem?.name}`) : t(`${i18nPrefix}.operations.title`)} + {selectedItem?.name ? t(`${i18nPrefix}.operations.${selectedItem?.name}` as any) as string : t(`${i18nPrefix}.operations.title` as any) as string}
@@ -83,7 +83,7 @@ const OperationSelector: FC = ({
-
{t(`${i18nPrefix}.operations.title`)}
+
{t(`${i18nPrefix}.operations.title` as any) as string}
{items.map(item => ( item.value === 'divider' @@ -100,7 +100,7 @@ const OperationSelector: FC = ({ }} >
- {t(`${i18nPrefix}.operations.${item.name}`)} + {t(`${i18nPrefix}.operations.${item.name}` as any) as string}
{item.value === value && (
diff --git a/web/app/components/workflow/nodes/assigner/node.tsx b/web/app/components/workflow/nodes/assigner/node.tsx index be30104242..3eb9f0d620 100644 --- a/web/app/components/workflow/nodes/assigner/node.tsx +++ b/web/app/components/workflow/nodes/assigner/node.tsx @@ -73,7 +73,7 @@ const NodeComponent: FC> = ({ nodeType={node?.data.type} nodeTitle={node?.data.title} rightSlot={ - writeMode && + writeMode && } />
diff --git a/web/app/components/workflow/nodes/if-else/components/condition-files-list-value.tsx b/web/app/components/workflow/nodes/if-else/components/condition-files-list-value.tsx index 53df68c337..0bb2245c71 100644 --- a/web/app/components/workflow/nodes/if-else/components/condition-files-list-value.tsx +++ b/web/app/components/workflow/nodes/if-else/components/condition-files-list-value.tsx @@ -34,7 +34,7 @@ const ConditionValue = ({ const variableSelector = variable_selector as ValueSelector - const operatorName = isComparisonOperatorNeedTranslate(operator) ? t(`workflow.nodes.ifElse.comparisonOperator.${operator}`) : operator + const operatorName = isComparisonOperatorNeedTranslate(operator) ? t(`workflow.nodes.ifElse.comparisonOperator.${operator}` as any) as string : operator const formatValue = useCallback((c: Condition) => { const notHasValue = comparisonOperatorNotRequireValue(c.comparison_operator) if (notHasValue) @@ -59,7 +59,7 @@ const ConditionValue = ({ if (isSelect) { const name = [...FILE_TYPE_OPTIONS, ...TRANSFER_METHOD].filter(item => item.value === (Array.isArray(c.value) ? c.value[0] : c.value))[0] return name - ? t(`workflow.nodes.ifElse.optionName.${name.i18nKey}`).replace(/\{\{#([^#]*)#\}\}/g, (a, b) => { + ? (t(`workflow.nodes.ifElse.optionName.${name.i18nKey}` as any) as string).replace(/\{\{#([^#]*)#\}\}/g, (a, b) => { const arr: string[] = b.split('.') if (isSystemVar(arr)) return `{{${b}}}` @@ -91,9 +91,9 @@ const ConditionValue = ({ sub_variable_condition?.conditions.map((c: Condition, index) => (
{c.key}
-
{isComparisonOperatorNeedTranslate(c.comparison_operator) ? t(`workflow.nodes.ifElse.comparisonOperator.${c.comparison_operator}`) : c.comparison_operator}
+
{isComparisonOperatorNeedTranslate(c.comparison_operator) ? t(`workflow.nodes.ifElse.comparisonOperator.${c.comparison_operator}` as any) as string : c.comparison_operator}
{c.comparison_operator && !isEmptyRelatedOperator(c.comparison_operator) &&
{isSelect(c) ? selectName(c) : formatValue(c)}
} - {index !== sub_variable_condition.conditions.length - 1 && (
{t(`${i18nPrefix}.${sub_variable_condition.logical_operator}`)}
)} + {index !== sub_variable_condition.conditions.length - 1 && (
{t(`${i18nPrefix}.${sub_variable_condition.logical_operator}` as any) as string}
)}
)) } diff --git a/web/app/components/workflow/nodes/if-else/components/condition-list/condition-item.tsx b/web/app/components/workflow/nodes/if-else/components/condition-list/condition-item.tsx index b323e65066..f49b8ef2fc 100644 --- a/web/app/components/workflow/nodes/if-else/components/condition-list/condition-item.tsx +++ b/web/app/components/workflow/nodes/if-else/components/condition-list/condition-item.tsx @@ -168,13 +168,13 @@ const ConditionItem = ({ if (isSelect) { if (fileAttr?.key === 'type' || condition.comparison_operator === ComparisonOperator.allOf) { return FILE_TYPE_OPTIONS.map(item => ({ - name: t(`${optionNameI18NPrefix}.${item.i18nKey}`), + name: t(`${optionNameI18NPrefix}.${item.i18nKey}` as any) as string, value: item.value, })) } if (fileAttr?.key === 'transfer_method') { return TRANSFER_METHOD.map(item => ({ - name: t(`${optionNameI18NPrefix}.${item.i18nKey}`), + name: t(`${optionNameI18NPrefix}.${item.i18nKey}` as any) as string, value: item.value, })) } 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 e2753ba6e7..64c6d35d8f 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 @@ -39,7 +39,7 @@ const ConditionOperator = ({ const options = useMemo(() => { return getOperators(varType, file).map((o) => { return { - label: isComparisonOperatorNeedTranslate(o) ? t(`${i18nPrefix}.comparisonOperator.${o}`) : o, + label: isComparisonOperatorNeedTranslate(o) ? t(`${i18nPrefix}.comparisonOperator.${o}` as any) as string : o, value: o, } }) @@ -65,7 +65,7 @@ const ConditionOperator = ({ { selectedOption ? selectedOption.label - : t(`${i18nPrefix}.select`) + : t(`${i18nPrefix}.select` as any) as string } diff --git a/web/app/components/workflow/nodes/if-else/components/condition-value.tsx b/web/app/components/workflow/nodes/if-else/components/condition-value.tsx index 376c3a670f..c0cd78e4c5 100644 --- a/web/app/components/workflow/nodes/if-else/components/condition-value.tsx +++ b/web/app/components/workflow/nodes/if-else/components/condition-value.tsx @@ -35,7 +35,7 @@ const ConditionValue = ({ const { t } = useTranslation() const nodes = useNodes() const variableName = labelName || (isSystemVar(variableSelector) ? variableSelector.slice(0).join('.') : variableSelector.slice(1).join('.')) - const operatorName = isComparisonOperatorNeedTranslate(operator) ? t(`workflow.nodes.ifElse.comparisonOperator.${operator}`) : operator + const operatorName = isComparisonOperatorNeedTranslate(operator) ? t(`workflow.nodes.ifElse.comparisonOperator.${operator}` as any) as string : operator const notHasValue = comparisonOperatorNotRequireValue(operator) const node: Node | undefined = nodes.find(n => n.id === variableSelector[0]) as Node const isException = isExceptionVariable(variableName, node?.data.type) @@ -63,7 +63,7 @@ const ConditionValue = ({ if (isSelect) { const name = [...FILE_TYPE_OPTIONS, ...TRANSFER_METHOD].filter(item => item.value === (Array.isArray(value) ? value[0] : value))[0] return name - ? t(`workflow.nodes.ifElse.optionName.${name.i18nKey}`).replace(/\{\{#([^#]*)#\}\}/g, (a, b) => { + ? (t(`workflow.nodes.ifElse.optionName.${name.i18nKey}` as any) as string).replace(/\{\{#([^#]*)#\}\}/g, (a, b) => { const arr: string[] = b.split('.') if (isSystemVar(arr)) return `{{${b}}}` diff --git a/web/app/components/workflow/nodes/iteration/use-interactions.ts b/web/app/components/workflow/nodes/iteration/use-interactions.ts index c6fddff5ad..87a4b8ad5d 100644 --- a/web/app/components/workflow/nodes/iteration/use-interactions.ts +++ b/web/app/components/workflow/nodes/iteration/use-interactions.ts @@ -135,7 +135,7 @@ export const useNodeIterationInteractions = () => { _isBundled: false, _connectedSourceHandleIds: [], _connectedTargetHandleIds: [], - title: nodesWithSameType.length > 0 ? `${t(`workflow.blocks.${childNodeType}`)} ${childNodeTypeCount[childNodeType]}` : t(`workflow.blocks.${childNodeType}`), + title: nodesWithSameType.length > 0 ? `${t(`workflow.blocks.${childNodeType}` as any) as string} ${childNodeTypeCount[childNodeType]}` : t(`workflow.blocks.${childNodeType}` as any) as string, iteration_id: newNodeId, type: childNodeType, }, diff --git a/web/app/components/workflow/nodes/knowledge-retrieval/components/dataset-item.tsx b/web/app/components/workflow/nodes/knowledge-retrieval/components/dataset-item.tsx index b3f2701524..5c764cba28 100644 --- a/web/app/components/workflow/nodes/knowledge-retrieval/components/dataset-item.tsx +++ b/web/app/components/workflow/nodes/knowledge-retrieval/components/dataset-item.tsx @@ -121,7 +121,7 @@ const DatasetItem: FC = ({ payload.provider === 'external' && ( ) } 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 8f0430b655..d248d96dc0 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 @@ -42,7 +42,7 @@ const ConditionOperator = ({ const options = useMemo(() => { return getOperators(variableType).map((o) => { return { - label: isComparisonOperatorNeedTranslate(o) ? t(`${i18nPrefix}.comparisonOperator.${o}`) : o, + label: isComparisonOperatorNeedTranslate(o) ? t(`${i18nPrefix}.comparisonOperator.${o}` as any) as string : o, value: o, } }) @@ -68,7 +68,7 @@ const ConditionOperator = ({ { selectedOption ? selectedOption.label - : t(`${i18nPrefix}.select`) + : t(`${i18nPrefix}.select` as any) as string } diff --git a/web/app/components/workflow/nodes/list-operator/components/filter-condition.tsx b/web/app/components/workflow/nodes/list-operator/components/filter-condition.tsx index 8dd817a5ad..1ca931d32c 100644 --- a/web/app/components/workflow/nodes/list-operator/components/filter-condition.tsx +++ b/web/app/components/workflow/nodes/list-operator/components/filter-condition.tsx @@ -66,13 +66,13 @@ const FilterCondition: FC = ({ if (isSelect) { if (condition.key === 'type' || condition.comparison_operator === ComparisonOperator.allOf) { return FILE_TYPE_OPTIONS.map(item => ({ - name: t(`${optionNameI18NPrefix}.${item.i18nKey}`), + name: t(`${optionNameI18NPrefix}.${item.i18nKey}` as any) as string, value: item.value, })) } if (condition.key === 'transfer_method') { return TRANSFER_METHOD.map(item => ({ - name: t(`${optionNameI18NPrefix}.${item.i18nKey}`), + name: t(`${optionNameI18NPrefix}.${item.i18nKey}` as any) as string, value: item.value, })) } diff --git a/web/app/components/workflow/nodes/llm/components/config-prompt-item.tsx b/web/app/components/workflow/nodes/llm/components/config-prompt-item.tsx index 776ad6804c..64d7a24a53 100644 --- a/web/app/components/workflow/nodes/llm/components/config-prompt-item.tsx +++ b/web/app/components/workflow/nodes/llm/components/config-prompt-item.tsx @@ -121,7 +121,7 @@ const ConfigPromptItem: FC = ({ {t(`${i18nPrefix}.roleDescription.${payload.role}`)}
+
{t(`${i18nPrefix}.roleDescription.${payload.role}` as any) as string}
} triggerClassName="w-4 h-4" /> diff --git a/web/app/components/workflow/nodes/loop/components/condition-files-list-value.tsx b/web/app/components/workflow/nodes/loop/components/condition-files-list-value.tsx index e13832ed46..dfe16902fd 100644 --- a/web/app/components/workflow/nodes/loop/components/condition-files-list-value.tsx +++ b/web/app/components/workflow/nodes/loop/components/condition-files-list-value.tsx @@ -34,7 +34,7 @@ const ConditionValue = ({ const variableSelector = variable_selector as ValueSelector - const operatorName = isComparisonOperatorNeedTranslate(operator) ? t(`workflow.nodes.ifElse.comparisonOperator.${operator}`) : operator + const operatorName = isComparisonOperatorNeedTranslate(operator) ? t(`workflow.nodes.ifElse.comparisonOperator.${operator}` as any) as string : operator const formatValue = useCallback((c: Condition) => { const notHasValue = comparisonOperatorNotRequireValue(c.comparison_operator) if (notHasValue) @@ -59,7 +59,7 @@ const ConditionValue = ({ if (isSelect) { const name = [...FILE_TYPE_OPTIONS, ...TRANSFER_METHOD].filter(item => item.value === (Array.isArray(c.value) ? c.value[0] : c.value))[0] return name - ? t(`workflow.nodes.ifElse.optionName.${name.i18nKey}`).replace(/\{\{#([^#]*)#\}\}/g, (a, b) => { + ? (t(`workflow.nodes.ifElse.optionName.${name.i18nKey}` as any) as string).replace(/\{\{#([^#]*)#\}\}/g, (a: string, b: string) => { const arr: string[] = b.split('.') if (isSystemVar(arr)) return `{{${b}}}` @@ -91,9 +91,9 @@ const ConditionValue = ({ sub_variable_condition?.conditions.map((c: Condition, index) => (
{c.key}
-
{isComparisonOperatorNeedTranslate(c.comparison_operator) ? t(`workflow.nodes.ifElse.comparisonOperator.${c.comparison_operator}`) : c.comparison_operator}
+
{isComparisonOperatorNeedTranslate(c.comparison_operator) ? t(`workflow.nodes.ifElse.comparisonOperator.${c.comparison_operator}` as any) as string : c.comparison_operator}
{c.comparison_operator && !isEmptyRelatedOperator(c.comparison_operator) &&
{isSelect(c) ? selectName(c) : formatValue(c)}
} - {index !== sub_variable_condition.conditions.length - 1 && (
{t(`${i18nPrefix}.${sub_variable_condition.logical_operator}`)}
)} + {index !== sub_variable_condition.conditions.length - 1 && (
{t(`${i18nPrefix}.${sub_variable_condition.logical_operator}` as any) as string}
)}
)) } diff --git a/web/app/components/workflow/nodes/loop/components/condition-list/condition-item.tsx b/web/app/components/workflow/nodes/loop/components/condition-list/condition-item.tsx index ea3e2ef5be..95e7b58dd0 100644 --- a/web/app/components/workflow/nodes/loop/components/condition-list/condition-item.tsx +++ b/web/app/components/workflow/nodes/loop/components/condition-list/condition-item.tsx @@ -145,13 +145,13 @@ const ConditionItem = ({ if (isSelect) { if (fileAttr?.key === 'type' || condition.comparison_operator === ComparisonOperator.allOf) { return FILE_TYPE_OPTIONS.map(item => ({ - name: t(`${optionNameI18NPrefix}.${item.i18nKey}`), + name: t(`${optionNameI18NPrefix}.${item.i18nKey}` as any) as string, value: item.value, })) } if (fileAttr?.key === 'transfer_method') { return TRANSFER_METHOD.map(item => ({ - name: t(`${optionNameI18NPrefix}.${item.i18nKey}`), + name: t(`${optionNameI18NPrefix}.${item.i18nKey}` as any) as string, value: item.value, })) } diff --git a/web/app/components/workflow/nodes/loop/components/condition-list/condition-operator.tsx b/web/app/components/workflow/nodes/loop/components/condition-list/condition-operator.tsx index a33b2b7727..9943109c2b 100644 --- a/web/app/components/workflow/nodes/loop/components/condition-list/condition-operator.tsx +++ b/web/app/components/workflow/nodes/loop/components/condition-list/condition-operator.tsx @@ -39,7 +39,7 @@ const ConditionOperator = ({ const options = useMemo(() => { return getOperators(varType, file).map((o) => { return { - label: isComparisonOperatorNeedTranslate(o) ? t(`${i18nPrefix}.comparisonOperator.${o}`) : o, + label: isComparisonOperatorNeedTranslate(o) ? t(`${i18nPrefix}.comparisonOperator.${o}` as any) as string : o, value: o, } }) @@ -65,7 +65,7 @@ const ConditionOperator = ({ { selectedOption ? selectedOption.label - : t(`${i18nPrefix}.select`) + : t(`${i18nPrefix}.select` as any) as string } diff --git a/web/app/components/workflow/nodes/loop/components/condition-value.tsx b/web/app/components/workflow/nodes/loop/components/condition-value.tsx index c24a1a18a6..10fa2cef42 100644 --- a/web/app/components/workflow/nodes/loop/components/condition-value.tsx +++ b/web/app/components/workflow/nodes/loop/components/condition-value.tsx @@ -27,7 +27,7 @@ const ConditionValue = ({ value, }: ConditionValueProps) => { const { t } = useTranslation() - const operatorName = isComparisonOperatorNeedTranslate(operator) ? t(`workflow.nodes.ifElse.comparisonOperator.${operator}`) : operator + const operatorName = isComparisonOperatorNeedTranslate(operator) ? t(`workflow.nodes.ifElse.comparisonOperator.${operator}` as any) as string : operator const notHasValue = comparisonOperatorNotRequireValue(operator) const formatValue = useMemo(() => { if (notHasValue) @@ -50,7 +50,7 @@ const ConditionValue = ({ if (isSelect) { const name = [...FILE_TYPE_OPTIONS, ...TRANSFER_METHOD].filter(item => item.value === (Array.isArray(value) ? value[0] : value))[0] return name - ? t(`workflow.nodes.ifElse.optionName.${name.i18nKey}`).replace(/\{\{#([^#]*)#\}\}/g, (a, b) => { + ? (t(`workflow.nodes.ifElse.optionName.${name.i18nKey}` as any) as string).replace(/\{\{#([^#]*)#\}\}/g, (a, b) => { const arr: string[] = b.split('.') if (isSystemVar(arr)) return `{{${b}}}` diff --git a/web/app/components/workflow/nodes/loop/components/loop-variables/item.tsx b/web/app/components/workflow/nodes/loop/components/loop-variables/item.tsx index 973e78ae73..949c53ca97 100644 --- a/web/app/components/workflow/nodes/loop/components/loop-variables/item.tsx +++ b/web/app/components/workflow/nodes/loop/components/loop-variables/item.tsx @@ -30,7 +30,7 @@ const Item = ({ if (!isValid) { Toast.notify({ type: 'error', - message: t(`appDebug.varKeyError.${errorMessageKey}`, { key: t('workflow.env.modal.name') }), + message: t(`appDebug.varKeyError.${errorMessageKey}` as any, { key: t('workflow.env.modal.name') }) as string, }) return false } diff --git a/web/app/components/workflow/nodes/parameter-extractor/components/extract-parameter/update.tsx b/web/app/components/workflow/nodes/parameter-extractor/components/extract-parameter/update.tsx index 288e486ea7..61921296d4 100644 --- a/web/app/components/workflow/nodes/parameter-extractor/components/extract-parameter/update.tsx +++ b/web/app/components/workflow/nodes/parameter-extractor/components/extract-parameter/update.tsx @@ -56,7 +56,7 @@ const AddExtractParameter: FC = ({ if (!isValid) { Toast.notify({ type: 'error', - message: t(`appDebug.varKeyError.${errorMessageKey}`, { key: errorKey }), + message: t(`appDebug.varKeyError.${errorMessageKey}` as any, { key: errorKey }) as string, }) return } diff --git a/web/app/components/workflow/nodes/start/components/var-list.tsx b/web/app/components/workflow/nodes/start/components/var-list.tsx index bda45ca5dd..4d3c6cd871 100644 --- a/web/app/components/workflow/nodes/start/components/var-list.tsx +++ b/web/app/components/workflow/nodes/start/components/var-list.tsx @@ -45,7 +45,7 @@ const VarList: FC = ({ if (errorMsgKey) { Toast.notify({ type: 'error', - message: t(errorMsgKey, { key: t(typeName) }), + message: t(errorMsgKey as any, { key: t(typeName as any) as string }) as string, }) return false } diff --git a/web/app/components/workflow/nodes/start/use-config.ts b/web/app/components/workflow/nodes/start/use-config.ts index 8eed650f98..e563e710ce 100644 --- a/web/app/components/workflow/nodes/start/use-config.ts +++ b/web/app/components/workflow/nodes/start/use-config.ts @@ -99,7 +99,7 @@ const useConfig = (id: string, payload: StartNodeType) => { if (errorMsgKey) { Toast.notify({ type: 'error', - message: t(errorMsgKey, { key: t(typeName) }), + message: t(errorMsgKey as any, { key: t(typeName as any) as string }) as string, }) return false } diff --git a/web/app/components/workflow/nodes/trigger-schedule/components/mode-switcher.tsx b/web/app/components/workflow/nodes/trigger-schedule/components/mode-switcher.tsx index 7c1f4e8f9d..de0ac01cb6 100644 --- a/web/app/components/workflow/nodes/trigger-schedule/components/mode-switcher.tsx +++ b/web/app/components/workflow/nodes/trigger-schedule/components/mode-switcher.tsx @@ -15,12 +15,12 @@ const ModeSwitcher = ({ mode, onChange }: ModeSwitcherProps) => { const options = [ { Icon: RiCalendarLine, - text: t('workflow.nodes.triggerSchedule.mode.visual'), + text: t('workflow.nodes.triggerSchedule.modeVisual'), value: 'visual' as const, }, { Icon: RiCodeLine, - text: t('workflow.nodes.triggerSchedule.mode.cron'), + text: t('workflow.nodes.triggerSchedule.modeCron'), value: 'cron' as const, }, ] diff --git a/web/app/components/workflow/nodes/trigger-webhook/use-config.ts b/web/app/components/workflow/nodes/trigger-webhook/use-config.ts index dec79b8eaf..03cc72b237 100644 --- a/web/app/components/workflow/nodes/trigger-webhook/use-config.ts +++ b/web/app/components/workflow/nodes/trigger-webhook/use-config.ts @@ -103,9 +103,9 @@ const useConfig = (id: string, payload: WebhookTriggerNodeType) => { if (!isValid) { Toast.notify({ type: 'error', - message: t(`appDebug.varKeyError.${errorMessageKey}`, { + message: t(`appDebug.varKeyError.${errorMessageKey}` as any, { key: t('appDebug.variableConfig.varName'), - }), + }) as string, }) return false } diff --git a/web/app/components/workflow/nodes/variable-assigner/components/node-variable-item.tsx b/web/app/components/workflow/nodes/variable-assigner/components/node-variable-item.tsx index d722c1d231..d1274ee65f 100644 --- a/web/app/components/workflow/nodes/variable-assigner/components/node-variable-item.tsx +++ b/web/app/components/workflow/nodes/variable-assigner/components/node-variable-item.tsx @@ -120,7 +120,7 @@ const NodeVariableItem = ({ {VariableIcon} {VariableName}
- {writeMode && } + {writeMode && }
) } diff --git a/web/app/components/workflow/nodes/variable-assigner/components/var-group-item.tsx b/web/app/components/workflow/nodes/variable-assigner/components/var-group-item.tsx index 8fb1cfba61..f8b6298a9b 100644 --- a/web/app/components/workflow/nodes/variable-assigner/components/var-group-item.tsx +++ b/web/app/components/workflow/nodes/variable-assigner/components/var-group-item.tsx @@ -96,7 +96,7 @@ const VarGroupItem: FC = ({ if (!isValid) { Toast.notify({ type: 'error', - message: t(`appDebug.varKeyError.${errorMessageKey}`, { key: errorKey }), + message: t(`appDebug.varKeyError.${errorMessageKey}` as any, { key: errorKey }) as string, }) return } diff --git a/web/app/components/workflow/panel/chat-variable-panel/components/variable-modal.tsx b/web/app/components/workflow/panel/chat-variable-panel/components/variable-modal.tsx index 33e2e07376..aafeffb54a 100644 --- a/web/app/components/workflow/panel/chat-variable-panel/components/variable-modal.tsx +++ b/web/app/components/workflow/panel/chat-variable-panel/components/variable-modal.tsx @@ -127,7 +127,7 @@ const ChatVariableModal = ({ if (!isValid) { notify({ type: 'error', - message: t(`appDebug.varKeyError.${errorMessageKey}`, { key: t('workflow.env.modal.name') }), + message: t(`appDebug.varKeyError.${errorMessageKey}` as any, { key: t('workflow.env.modal.name') }) as string, }) return false } 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 e253d6c27c..383e15f20b 100644 --- a/web/app/components/workflow/panel/env-panel/variable-modal.tsx +++ b/web/app/components/workflow/panel/env-panel/variable-modal.tsx @@ -37,7 +37,7 @@ const VariableModal = ({ if (!isValid) { notify({ type: 'error', - message: t(`appDebug.varKeyError.${errorMessageKey}`, { key: t('workflow.env.modal.name') }), + message: t(`appDebug.varKeyError.${errorMessageKey}` as any, { key: t('workflow.env.modal.name') }) as string, }) return false } diff --git a/web/app/components/workflow/run/loop-result-panel.tsx b/web/app/components/workflow/run/loop-result-panel.tsx index 8238be82f3..6e37657057 100644 --- a/web/app/components/workflow/run/loop-result-panel.tsx +++ b/web/app/components/workflow/run/loop-result-panel.tsx @@ -43,7 +43,7 @@ const LoopResultPanel: FC = ({
- {t(`${i18nPrefix}.testRunLoop`)} + {t(`${i18nPrefix}.testRunLoop` as any) as string}
diff --git a/web/app/forgot-password/ForgotPasswordForm.tsx b/web/app/forgot-password/ForgotPasswordForm.tsx index 2b50c1c452..f06b952b68 100644 --- a/web/app/forgot-password/ForgotPasswordForm.tsx +++ b/web/app/forgot-password/ForgotPasswordForm.tsx @@ -108,7 +108,7 @@ const ForgotPasswordForm = () => { {...register('email')} placeholder={t('login.emailPlaceholder') || ''} /> - {errors.email && {t(`${errors.email?.message}`)}} + {errors.email && {t(`${errors.email?.message}` as any) as string}}
)} diff --git a/web/app/install/installForm.tsx b/web/app/install/installForm.tsx index 60de8e0501..f0290cca50 100644 --- a/web/app/install/installForm.tsx +++ b/web/app/install/installForm.tsx @@ -138,7 +138,7 @@ const InstallForm = () => { placeholder={t('login.emailPlaceholder') || ''} className="system-sm-regular w-full appearance-none rounded-md border border-transparent bg-components-input-bg-normal px-3 py-[7px] text-components-input-text-filled caret-primary-600 outline-none placeholder:text-components-input-text-placeholder hover:border-components-input-border-hover hover:bg-components-input-bg-hover focus:border-components-input-border-active focus:bg-components-input-bg-active focus:shadow-xs" /> - {errors.email && {t(`${errors.email?.message}`)}} + {errors.email && {t(`${errors.email?.message}` as any) as string}}
@@ -154,7 +154,7 @@ const InstallForm = () => { className="system-sm-regular w-full appearance-none rounded-md border border-transparent bg-components-input-bg-normal px-3 py-[7px] text-components-input-text-filled caret-primary-600 outline-none placeholder:text-components-input-text-placeholder hover:border-components-input-border-hover hover:bg-components-input-bg-hover focus:border-components-input-border-active focus:bg-components-input-bg-active focus:shadow-xs" />
- {errors.name && {t(`${errors.name.message}`)}} + {errors.name && {t(`${errors.name.message}` as any) as string}}
diff --git a/web/app/signin/invite-settings/page.tsx b/web/app/signin/invite-settings/page.tsx index 9abd4366e1..e3bbe420bc 100644 --- a/web/app/signin/invite-settings/page.tsx +++ b/web/app/signin/invite-settings/page.tsx @@ -1,4 +1,5 @@ 'use client' +import type { Locale } from '@/i18n-config' import { RiAccountCircleLine } from '@remixicon/react' import { noop } from 'lodash-es' import Link from 'next/link' @@ -123,7 +124,7 @@ export default function InviteSettingsPage() { defaultValue={LanguagesSupported[0]} items={languages.filter(item => item.supported)} onSelect={(item) => { - setLanguage(item.value as string) + setLanguage(item.value as Locale) }} />
diff --git a/web/hooks/use-format-time-from-now.ts b/web/hooks/use-format-time-from-now.ts index 09d8db7321..970a64e7d5 100644 --- a/web/hooks/use-format-time-from-now.ts +++ b/web/hooks/use-format-time-from-now.ts @@ -1,8 +1,8 @@ -import type { Locale } from '@/i18n-config' import dayjs from 'dayjs' import relativeTime from 'dayjs/plugin/relativeTime' import { useCallback } from 'react' import { useI18N } from '@/context/i18n' +import { localeMap } from '@/i18n-config/language' import 'dayjs/locale/de' import 'dayjs/locale/es' import 'dayjs/locale/fa' @@ -26,30 +26,6 @@ import 'dayjs/locale/zh-tw' dayjs.extend(relativeTime) -const localeMap: Record = { - 'en-US': 'en', - 'zh-Hans': 'zh-cn', - 'zh-Hant': 'zh-tw', - 'pt-BR': 'pt-br', - 'es-ES': 'es', - 'fr-FR': 'fr', - 'de-DE': 'de', - 'ja-JP': 'ja', - 'ko-KR': 'ko', - 'ru-RU': 'ru', - 'it-IT': 'it', - 'th-TH': 'th', - 'id-ID': 'id', - 'uk-UA': 'uk', - 'vi-VN': 'vi', - 'ro-RO': 'ro', - 'pl-PL': 'pl', - 'hi-IN': 'hi', - 'tr-TR': 'tr', - 'fa-IR': 'fa', - 'sl-SI': 'sl', -} - export const useFormatTimeFromNow = () => { const { locale } = useI18N() const formatTimeFromNow = useCallback((time: number) => { diff --git a/web/hooks/use-knowledge.ts b/web/hooks/use-knowledge.ts index 400d9722de..e3c2cd49d1 100644 --- a/web/hooks/use-knowledge.ts +++ b/web/hooks/use-knowledge.ts @@ -5,14 +5,14 @@ export const useKnowledge = () => { const { t } = useTranslation() const formatIndexingTechnique = useCallback((indexingTechnique: string) => { - return t(`dataset.indexingTechnique.${indexingTechnique}`) + return t(`dataset.indexingTechnique.${indexingTechnique}` as any) as string }, [t]) const formatIndexingMethod = useCallback((indexingMethod: string, isEco?: boolean) => { if (isEco) return t('dataset.indexingMethod.invertedIndex') - return t(`dataset.indexingMethod.${indexingMethod}`) + return t(`dataset.indexingMethod.${indexingMethod}` as any) as string }, [t]) const formatIndexingTechniqueAndMethod = useCallback((indexingTechnique: string, indexingMethod: string) => { diff --git a/web/hooks/use-metadata.ts b/web/hooks/use-metadata.ts index a51e6b150e..6b0946b68d 100644 --- a/web/hooks/use-metadata.ts +++ b/web/hooks/use-metadata.ts @@ -86,7 +86,7 @@ export const useMetadataMap = (): MetadataMap => { }, 'volume/issue/page_numbers': { label: t(`${fieldPrefix}.paper.volumeIssuePage`) }, 'doi': { label: t(`${fieldPrefix}.paper.DOI`) }, - 'topic/keywords': { label: t(`${fieldPrefix}.paper.topicKeywords`) }, + 'topic/keywords': { label: t(`${fieldPrefix}.paper.topicKeywords` as any) as string }, 'abstract': { label: t(`${fieldPrefix}.paper.abstract`), inputType: 'textarea', @@ -160,7 +160,7 @@ export const useMetadataMap = (): MetadataMap => { 'end_date': { label: t(`${fieldPrefix}.IMChat.endDate`) }, 'participants': { label: t(`${fieldPrefix}.IMChat.participants`) }, 'topicKeywords': { - label: t(`${fieldPrefix}.IMChat.topicKeywords`), + label: t(`${fieldPrefix}.IMChat.topicKeywords` as any) as string, inputType: 'textarea', }, 'fileType': { label: t(`${fieldPrefix}.IMChat.fileType`) }, @@ -193,7 +193,7 @@ export const useMetadataMap = (): MetadataMap => { allowEdit: false, subFieldsMap: { 'title': { label: t(`${fieldPrefix}.notion.title`) }, - 'language': { label: t(`${fieldPrefix}.notion.lang`), inputType: 'select' }, + 'language': { label: t(`${fieldPrefix}.notion.lang` as any) as string, inputType: 'select' }, 'author/creator': { label: t(`${fieldPrefix}.notion.author`) }, 'creation_date': { label: t(`${fieldPrefix}.notion.createdTime`) }, 'last_modified_date': { @@ -201,7 +201,7 @@ export const useMetadataMap = (): MetadataMap => { }, 'notion_page_link': { label: t(`${fieldPrefix}.notion.url`) }, 'category/tags': { label: t(`${fieldPrefix}.notion.tag`) }, - 'description': { label: t(`${fieldPrefix}.notion.desc`) }, + 'description': { label: t(`${fieldPrefix}.notion.desc` as any) as string }, }, }, synced_from_github: { @@ -241,7 +241,7 @@ export const useMetadataMap = (): MetadataMap => { }, 'data_source_type': { label: t(`${fieldPrefix}.originInfo.source`), - render: value => t(`datasetDocuments.metadata.source.${value === 'notion_import' ? 'notion' : value}`), + render: value => t(`datasetDocuments.metadata.source.${value === 'notion_import' ? 'notion' : value}` as any) as string, }, }, }, @@ -323,7 +323,7 @@ export const useLanguages = () => { cs: t(`${langPrefix}cs`), th: t(`${langPrefix}th`), id: t(`${langPrefix}id`), - ro: t(`${langPrefix}ro`), + ro: t(`${langPrefix}ro` as any) as string, } } diff --git a/web/i18n-config/README.md b/web/i18n-config/README.md index 0fe8922345..c724d94aa7 100644 --- a/web/i18n-config/README.md +++ b/web/i18n-config/README.md @@ -55,28 +55,9 @@ cp -r en-US id-ID 1. Add type to new language in the `language.ts` file. -```typescript -export type I18nText = { - 'en-US': string - 'zh-Hans': string - 'pt-BR': string - 'es-ES': string - 'fr-FR': string - 'de-DE': string - 'ja-JP': string - 'ko-KR': string - 'ru-RU': string - 'it-IT': string - 'uk-UA': string - 'id-ID': string - 'tr-TR': string - 'fa-IR': string - 'ar-TN': string - 'YOUR_LANGUAGE_CODE': string -} -``` +> Note: `I18nText` type is now automatically derived from `LanguagesSupported`, so you don't need to manually add types. -4. Add the new language to the `language.json` file. +4. Add the new language to the `languages.ts` file. ```typescript export const languages = [ @@ -189,11 +170,10 @@ We have a list of languages that we support in the `language.ts` file. But some ## Utility scripts -- Auto-fill translations: `pnpm run auto-gen-i18n -- --file app common --lang zh-Hans ja-JP [--dry-run]` +- Auto-fill translations: `pnpm run auto-gen-i18n --file app common --lang zh-Hans ja-JP [--dry-run]` - Use space-separated values; repeat `--file` / `--lang` as needed. Defaults to all en-US files and all supported locales except en-US. - Protects placeholders (`{{var}}`, `${var}`, ``) before translation and restores them after. -- Check missing/extra keys: `pnpm run check-i18n -- --file app billing --lang zh-Hans [--auto-remove]` +- Check missing/extra keys: `pnpm run check-i18n --file app billing --lang zh-Hans [--auto-remove]` - Use space-separated values; repeat `--file` / `--lang` as needed. Returns non-zero on missing/extra keys (CI will fail); `--auto-remove` deletes extra keys automatically. -- Generate types: `pnpm run gen:i18n-types`; verify sync: `pnpm run check:i18n-types`. -Workflows: `.github/workflows/translate-i18n-base-on-english.yml` auto-runs the translation generator on en-US changes to main; `.github/workflows/web-tests.yml` checks i18n keys and type sync on web changes. +Workflows: `.github/workflows/translate-i18n-base-on-english.yml` auto-runs the translation generator on en-US changes to main; `.github/workflows/web-tests.yml` checks i18n keys on web changes. diff --git a/web/i18n-config/auto-gen-i18n.js b/web/i18n-config/auto-gen-i18n.js index 561fa95869..6c8cb05bbd 100644 --- a/web/i18n-config/auto-gen-i18n.js +++ b/web/i18n-config/auto-gen-i18n.js @@ -6,11 +6,11 @@ import vm from 'node:vm' import { translate } from 'bing-translate-api' import { generateCode, loadFile, parseModule } from 'magicast' import { transpile } from 'typescript' +import data from './languages' const require = createRequire(import.meta.url) const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) -const data = require('./languages.json') const targetLanguage = 'en-US' const i18nFolder = '../i18n' // Path to i18n folder relative to this script @@ -117,8 +117,8 @@ Options: -h, --help Show help Examples: - pnpm run auto-gen-i18n -- --file app common --lang zh-Hans ja-JP - pnpm run auto-gen-i18n -- --dry-run + pnpm run auto-gen-i18n --file app common --lang zh-Hans ja-JP + pnpm run auto-gen-i18n --dry-run `) } diff --git a/web/i18n-config/check-i18n-sync.js b/web/i18n-config/check-i18n-sync.js deleted file mode 100644 index af00d23875..0000000000 --- a/web/i18n-config/check-i18n-sync.js +++ /dev/null @@ -1,134 +0,0 @@ -#!/usr/bin/env node - -import fs from 'node:fs' -import path from 'node:path' -import { fileURLToPath } from 'node:url' -import lodash from 'lodash' -import ts from 'typescript' - -const { camelCase } = lodash - -const __filename = fileURLToPath(import.meta.url) -const __dirname = path.dirname(__filename) - -// Import the NAMESPACES array from i18next-config.ts -function getNamespacesFromConfig() { - const configPath = path.join(__dirname, 'i18next-config.ts') - const configContent = fs.readFileSync(configPath, 'utf8') - const sourceFile = ts.createSourceFile(configPath, configContent, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS) - - const namespaces = [] - - const visit = (node) => { - if ( - ts.isVariableDeclaration(node) - && node.name.getText() === 'NAMESPACES' - && node.initializer - && ts.isArrayLiteralExpression(node.initializer) - ) { - node.initializer.elements.forEach((el) => { - if (ts.isStringLiteral(el)) - namespaces.push(el.text) - }) - } - ts.forEachChild(node, visit) - } - - visit(sourceFile) - - if (!namespaces.length) - throw new Error('Could not find NAMESPACES array in i18next-config.ts') - - return namespaces -} - -function getNamespacesFromTypes() { - const typesPath = path.join(__dirname, '../types/i18n.d.ts') - - if (!fs.existsSync(typesPath)) { - return null - } - - const typesContent = fs.readFileSync(typesPath, 'utf8') - - // Extract namespaces from Messages type - const messagesMatch = typesContent.match(/export type Messages = \{([\s\S]*?)\}/) - if (!messagesMatch) { - return null - } - - // Parse the properties - const propertiesStr = messagesMatch[1] - const properties = propertiesStr - .split('\n') - .map(line => line.trim()) - .filter(line => line.includes(':')) - .map(line => line.split(':')[0].trim()) - .filter(prop => prop.length > 0) - - return properties -} - -function main() { - try { - console.log('🔍 Checking i18n types synchronization...') - - // Get namespaces from config - const configNamespaces = getNamespacesFromConfig() - console.log(`📦 Found ${configNamespaces.length} namespaces in config`) - - // Convert to camelCase for comparison - const configCamelCase = configNamespaces.map(ns => camelCase(ns)).sort() - - // Get namespaces from type definitions - const typeNamespaces = getNamespacesFromTypes() - - if (!typeNamespaces) { - console.error('❌ Type definitions file not found or invalid') - console.error(' Run: pnpm run gen:i18n-types') - process.exit(1) - } - - console.log(`🔧 Found ${typeNamespaces.length} namespaces in types`) - - const typeCamelCase = typeNamespaces.sort() - - // Compare arrays - const configSet = new Set(configCamelCase) - const typeSet = new Set(typeCamelCase) - - // Find missing in types - const missingInTypes = configCamelCase.filter(ns => !typeSet.has(ns)) - - // Find extra in types - const extraInTypes = typeCamelCase.filter(ns => !configSet.has(ns)) - - let hasErrors = false - - if (missingInTypes.length > 0) { - hasErrors = true - console.error('❌ Missing in type definitions:') - missingInTypes.forEach(ns => console.error(` - ${ns}`)) - } - - if (extraInTypes.length > 0) { - hasErrors = true - console.error('❌ Extra in type definitions:') - extraInTypes.forEach(ns => console.error(` - ${ns}`)) - } - - if (hasErrors) { - console.error('\n💡 To fix synchronization issues:') - console.error(' Run: pnpm run gen:i18n-types') - process.exit(1) - } - - console.log('✅ i18n types are synchronized') - } - catch (error) { - console.error('❌ Error:', error.message) - process.exit(1) - } -} - -main() diff --git a/web/i18n-config/check-i18n.js b/web/i18n-config/check-i18n.js index d69885e6f0..d70564556c 100644 --- a/web/i18n-config/check-i18n.js +++ b/web/i18n-config/check-i18n.js @@ -4,13 +4,13 @@ import path from 'node:path' import { fileURLToPath } from 'node:url' import vm from 'node:vm' import { transpile } from 'typescript' +import data from './languages' const require = createRequire(import.meta.url) const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) const targetLanguage = 'en-US' -const data = require('./languages.json') const languages = data.languages.filter(language => language.supported).map(language => language.value) @@ -103,8 +103,8 @@ Options: -h, --help Show help Examples: - pnpm run check-i18n -- --file app billing --lang zh-Hans ja-JP - pnpm run check-i18n -- --auto-remove + pnpm run check-i18n --file app billing --lang zh-Hans ja-JP + pnpm run check-i18n --auto-remove `) } diff --git a/web/i18n-config/generate-i18n-types.js b/web/i18n-config/generate-i18n-types.js deleted file mode 100644 index 0b3c0195af..0000000000 --- a/web/i18n-config/generate-i18n-types.js +++ /dev/null @@ -1,149 +0,0 @@ -#!/usr/bin/env node - -import fs from 'node:fs' -import path from 'node:path' -import { fileURLToPath } from 'node:url' -import lodash from 'lodash' -import ts from 'typescript' - -const { camelCase } = lodash -const __filename = fileURLToPath(import.meta.url) -const __dirname = path.dirname(__filename) - -// Import the NAMESPACES array from i18next-config.ts -function getNamespacesFromConfig() { - const configPath = path.join(__dirname, 'i18next-config.ts') - const configContent = fs.readFileSync(configPath, 'utf8') - const sourceFile = ts.createSourceFile(configPath, configContent, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS) - - const namespaces = [] - - const visit = (node) => { - if ( - ts.isVariableDeclaration(node) - && node.name.getText() === 'NAMESPACES' - && node.initializer - && ts.isArrayLiteralExpression(node.initializer) - ) { - node.initializer.elements.forEach((el) => { - if (ts.isStringLiteral(el)) - namespaces.push(el.text) - }) - } - ts.forEachChild(node, visit) - } - - visit(sourceFile) - - if (!namespaces.length) - throw new Error('Could not find NAMESPACES array in i18next-config.ts') - - return namespaces -} - -function generateTypeDefinitions(namespaces) { - const header = `// TypeScript type definitions for Dify's i18next configuration -// This file is auto-generated. Do not edit manually. -// To regenerate, run: pnpm run gen:i18n-types -import 'react-i18next' - -// Extract types from translation files using typeof import pattern` - - // Generate individual type definitions - const typeDefinitions = namespaces.map((namespace) => { - const typeName = `${camelCase(namespace).replace(/^\w/, c => c.toUpperCase())}Messages` - return `type ${typeName} = typeof import('../i18n/en-US/${namespace}').default` - }).join('\n') - - // Generate Messages interface - const messagesInterface = ` -// Complete type structure that matches i18next-config.ts camelCase conversion -export type Messages = { -${namespaces.map((namespace) => { - const camelCased = camelCase(namespace) - const typeName = `${camelCase(namespace).replace(/^\w/, c => c.toUpperCase())}Messages` - return ` ${camelCased}: ${typeName};` -}).join('\n')} -}` - - const utilityTypes = ` -// Utility type to flatten nested object keys into dot notation -type FlattenKeys = T extends object - ? { - [K in keyof T]: T[K] extends object - ? \`\${K & string}.\${FlattenKeys & string}\` - : \`\${K & string}\` - }[keyof T] - : never - -export type ValidTranslationKeys = FlattenKeys` - - const moduleDeclarations = ` -// Extend react-i18next with Dify's type structure -declare module 'react-i18next' { - interface CustomTypeOptions { - defaultNS: 'translation'; - resources: { - translation: Messages; - }; - } -} - -// Extend i18next for complete type safety -declare module 'i18next' { - interface CustomTypeOptions { - defaultNS: 'translation'; - resources: { - translation: Messages; - }; - } -}` - - return [header, typeDefinitions, messagesInterface, utilityTypes, moduleDeclarations].join('\n\n') -} - -function main() { - const args = process.argv.slice(2) - const checkMode = args.includes('--check') - - try { - console.log('📦 Generating i18n type definitions...') - - // Get namespaces from config - const namespaces = getNamespacesFromConfig() - console.log(`✅ Found ${namespaces.length} namespaces`) - - // Generate type definitions - const typeDefinitions = generateTypeDefinitions(namespaces) - - const outputPath = path.join(__dirname, '../types/i18n.d.ts') - - if (checkMode) { - // Check mode: compare with existing file - if (!fs.existsSync(outputPath)) { - console.error('❌ Type definitions file does not exist') - process.exit(1) - } - - const existingContent = fs.readFileSync(outputPath, 'utf8') - if (existingContent.trim() !== typeDefinitions.trim()) { - console.error('❌ Type definitions are out of sync') - console.error(' Run: pnpm run gen:i18n-types') - process.exit(1) - } - - console.log('✅ Type definitions are in sync') - } - else { - // Generate mode: write file - fs.writeFileSync(outputPath, typeDefinitions) - console.log(`✅ Generated type definitions: ${outputPath}`) - } - } - catch (error) { - console.error('❌ Error:', error.message) - process.exit(1) - } -} - -main() diff --git a/web/i18n-config/i18next-config.ts b/web/i18n-config/i18next-config.ts index b310d380e2..e82f5f2acb 100644 --- a/web/i18n-config/i18next-config.ts +++ b/web/i18n-config/i18next-config.ts @@ -1,10 +1,10 @@ 'use client' +import type { Locale } from '.' import i18n from 'i18next' -import { camelCase } from 'lodash-es' -import { initReactI18next } from 'react-i18next' +import { camelCase, kebabCase } from 'lodash-es' +import { initReactI18next } from 'react-i18next' import app from '../i18n/en-US/app' -// Static imports for en-US (fallback language) import appAnnotation from '../i18n/en-US/app-annotation' import appApi from '../i18n/en-US/app-api' import appDebug from '../i18n/en-US/app-debug' @@ -35,7 +35,56 @@ import time from '../i18n/en-US/time' import tools from '../i18n/en-US/tools' import workflow from '../i18n/en-US/workflow' -const requireSilent = async (lang: string, namespace: string) => { +// @keep-sorted +export const messagesEN = { + app, + appAnnotation, + appApi, + appDebug, + appLog, + appOverview, + billing, + common, + custom, + dataset, + datasetCreation, + datasetDocuments, + datasetHitTesting, + datasetPipeline, + datasetSettings, + education, + explore, + layout, + login, + oauth, + pipeline, + plugin, + pluginTags, + pluginTrigger, + register, + runLog, + share, + time, + tools, + workflow, +} + +// pluginTrigger -> plugin-trigger + +export type KebabCase = S extends `${infer T}${infer U}` + ? T extends Lowercase + ? `${T}${KebabCase}` + : `-${Lowercase}${KebabCase}` + : S + +export type CamelCase = S extends `${infer T}-${infer U}` + ? `${T}${Capitalize>}` + : S + +export type KeyPrefix = keyof typeof messagesEN +export type Namespace = KebabCase + +const requireSilent = async (lang: Locale, namespace: Namespace) => { let res try { res = (await import(`../i18n/${lang}/${namespace}`)).default @@ -47,40 +96,9 @@ const requireSilent = async (lang: string, namespace: string) => { return res } -const NAMESPACES = [ - 'app-annotation', - 'app-api', - 'app-debug', - 'app-log', - 'app-overview', - 'app', - 'billing', - 'common', - 'custom', - 'dataset-creation', - 'dataset-documents', - 'dataset-hit-testing', - 'dataset-pipeline', - 'dataset-settings', - 'dataset', - 'education', - 'explore', - 'layout', - 'login', - 'oauth', - 'pipeline', - 'plugin-tags', - 'plugin-trigger', - 'plugin', - 'register', - 'run-log', - 'share', - 'time', - 'tools', - 'workflow', -] +const NAMESPACES = Object.keys(messagesEN).map(kebabCase) as Namespace[] -export const loadLangResources = async (lang: string) => { +export const loadLangResources = async (lang: Locale) => { const modules = await Promise.all( NAMESPACES.map(ns => requireSilent(lang, ns)), ) @@ -93,41 +111,9 @@ export const loadLangResources = async (lang: string) => { // Load en-US resources first to make sure fallback works const getInitialTranslations = () => { - const en_USResources: Record = { - appAnnotation, - appApi, - appDebug, - appLog, - appOverview, - app, - billing, - common, - custom, - datasetCreation, - datasetDocuments, - datasetHitTesting, - datasetPipeline, - datasetSettings, - dataset, - education, - explore, - layout, - login, - oauth, - pipeline, - pluginTags, - pluginTrigger, - plugin, - register, - runLog, - share, - time, - tools, - workflow, - } return { 'en-US': { - translation: en_USResources, + translation: messagesEN, }, } } @@ -140,7 +126,7 @@ if (!i18n.isInitialized) { }) } -export const changeLanguage = async (lng?: string) => { +export const changeLanguage = async (lng?: Locale) => { if (!lng) return if (!i18n.hasResourceBundle(lng, 'translation')) { diff --git a/web/i18n-config/index.ts b/web/i18n-config/index.ts index 8a0f712f2a..bb73ef4b71 100644 --- a/web/i18n-config/index.ts +++ b/web/i18n-config/index.ts @@ -1,5 +1,6 @@ -import Cookies from 'js-cookie' +import type { Locale } from '@/i18n-config/language' +import Cookies from 'js-cookie' import { LOCALE_COOKIE_NAME } from '@/config' import { changeLanguage } from '@/i18n-config/i18next-config' import { LanguagesSupported } from '@/i18n-config/language' @@ -9,7 +10,7 @@ export const i18n = { locales: LanguagesSupported, } as const -export type Locale = typeof i18n['locales'][number] +export { Locale } export const setLocaleOnClient = async (locale: Locale, reloadPage = true) => { Cookies.set(LOCALE_COOKIE_NAME, locale, { expires: 365 }) diff --git a/web/i18n-config/language.ts b/web/i18n-config/language.ts index a1fe6e790f..28afd9eabf 100644 --- a/web/i18n-config/language.ts +++ b/web/i18n-config/language.ts @@ -1,4 +1,4 @@ -import data from './languages.json' +import data from './languages' export type Item = { value: number | string @@ -6,40 +6,20 @@ export type Item = { example: string } -export type I18nText = { - 'en-US': string - 'zh-Hans': string - 'zh-Hant': string - 'pt-BR': string - 'es-ES': string - 'fr-FR': string - 'de-DE': string - 'ja-JP': string - 'ko-KR': string - 'ru-RU': string - 'it-IT': string - 'th-TH': string - 'id-ID': string - 'uk-UA': string - 'vi-VN': string - 'ro-RO': string - 'pl-PL': string - 'hi-IN': string - 'tr-TR': string - 'fa-IR': string - 'sl-SI': string - 'ar-TN': string -} +export type I18nText = Record export const languages = data.languages -export const LanguagesSupported = languages.filter(item => item.supported).map(item => item.value) +// for compatibility +export type Locale = 'ja_JP' | 'zh_Hans' | 'en_US' | (typeof languages[number])['value'] -export const getLanguage = (locale: string) => { +export const LanguagesSupported: Locale[] = languages.filter(item => item.supported).map(item => item.value) + +export const getLanguage = (locale: Locale): Locale => { if (['zh-Hans', 'ja-JP'].includes(locale)) - return locale.replace('-', '_') + return locale.replace('-', '_') as Locale - return LanguagesSupported[0].replace('-', '_') + return LanguagesSupported[0].replace('-', '_') as Locale } const DOC_LANGUAGE: Record = { @@ -48,6 +28,34 @@ const DOC_LANGUAGE: Record = { 'en-US': 'en', } +export const localeMap: Record = { + 'en-US': 'en', + 'en_US': 'en', + 'zh-Hans': 'zh-cn', + 'zh_Hans': 'zh-cn', + 'zh-Hant': 'zh-tw', + 'pt-BR': 'pt-br', + 'es-ES': 'es', + 'fr-FR': 'fr', + 'de-DE': 'de', + 'ja-JP': 'ja', + 'ja_JP': 'ja', + 'ko-KR': 'ko', + 'ru-RU': 'ru', + 'it-IT': 'it', + 'th-TH': 'th', + 'id-ID': 'id', + 'uk-UA': 'uk', + 'vi-VN': 'vi', + 'ro-RO': 'ro', + 'pl-PL': 'pl', + 'hi-IN': 'hi', + 'tr-TR': 'tr', + 'fa-IR': 'fa', + 'sl-SI': 'sl', + 'ar-TN': 'ar', +} + export const getDocLanguage = (locale: string) => { return DOC_LANGUAGE[locale] || 'en' } diff --git a/web/i18n-config/languages.json b/web/i18n-config/languages.json deleted file mode 100644 index 6e0025b8de..0000000000 --- a/web/i18n-config/languages.json +++ /dev/null @@ -1,158 +0,0 @@ -{ - "languages": [ - { - "value": "en-US", - "name": "English (United States)", - "prompt_name": "English", - "example": "Hello, Dify!", - "supported": true - }, - { - "value": "zh-Hans", - "name": "简体中文", - "prompt_name": "Chinese Simplified", - "example": "你好,Dify!", - "supported": true - }, - { - "value": "zh-Hant", - "name": "繁體中文", - "prompt_name": "Chinese Traditional", - "example": "你好,Dify!", - "supported": true - }, - { - "value": "pt-BR", - "name": "Português (Brasil)", - "prompt_name": "Portuguese", - "example": "Olá, Dify!", - "supported": true - }, - { - "value": "es-ES", - "name": "Español (España)", - "prompt_name": "Spanish", - "example": "¡Hola, Dify!", - "supported": true - }, - { - "value": "fr-FR", - "name": "Français (France)", - "prompt_name": "French", - "example": "Bonjour, Dify!", - "supported": true - }, - { - "value": "de-DE", - "name": "Deutsch (Deutschland)", - "prompt_name": "German", - "example": "Hallo, Dify!", - "supported": true - }, - { - "value": "ja-JP", - "name": "日本語 (日本)", - "prompt_name": "Japanese", - "example": "こんにちは、Dify!", - "supported": true - }, - { - "value": "ko-KR", - "name": "한국어 (대한민국)", - "prompt_name": "Korean", - "example": "안녕하세요, Dify!", - "supported": true - }, - { - "value": "ru-RU", - "name": "Русский (Россия)", - "prompt_name": "Russian", - "example": " Привет, Dify!", - "supported": true - }, - { - "value": "it-IT", - "name": "Italiano (Italia)", - "prompt_name": "Italian", - "example": "Ciao, Dify!", - "supported": true - }, - { - "value": "th-TH", - "name": "ไทย (ประเทศไทย)", - "prompt_name": "Thai", - "example": "สวัสดี Dify!", - "supported": true - }, - { - "value": "uk-UA", - "name": "Українська (Україна)", - "prompt_name": "Ukrainian", - "example": "Привет, Dify!", - "supported": true - }, - { - "value": "vi-VN", - "name": "Tiếng Việt (Việt Nam)", - "prompt_name": "Vietnamese", - "example": "Xin chào, Dify!", - "supported": true - }, - { - "value": "ro-RO", - "name": "Română (România)", - "prompt_name": "Romanian", - "example": "Salut, Dify!", - "supported": true - }, - { - "value": "pl-PL", - "name": "Polski (Polish)", - "prompt_name": "Polish", - "example": "Cześć, Dify!", - "supported": true - }, - { - "value": "hi-IN", - "name": "Hindi (India)", - "prompt_name": "Hindi", - "example": "नमस्ते, Dify!", - "supported": true - }, - { - "value": "tr-TR", - "name": "Türkçe", - "prompt_name": "Türkçe", - "example": "Selam!", - "supported": true - }, - { - "value": "fa-IR", - "name": "Farsi (Iran)", - "prompt_name": "Farsi", - "example": "سلام, دیفای!", - "supported": true - }, - { - "value": "sl-SI", - "name": "Slovensko (Slovenija)", - "prompt_name": "Slovensko", - "example": "Zdravo, Dify!", - "supported": true - }, - { - "value": "id-ID", - "name": "Bahasa Indonesia", - "prompt_name": "Indonesian", - "example": "Halo, Dify!", - "supported": true - }, - { - "value": "ar-TN", - "name": "العربية (تونس)", - "prompt_name": "Tunisian Arabic", - "example": "مرحبا، Dify!", - "supported": true - } - ] -} diff --git a/web/i18n-config/languages.ts b/web/i18n-config/languages.ts new file mode 100644 index 0000000000..5077aee1d2 --- /dev/null +++ b/web/i18n-config/languages.ts @@ -0,0 +1,160 @@ +const data = { + languages: [ + { + value: 'en-US', + name: 'English (United States)', + prompt_name: 'English', + example: 'Hello, Dify!', + supported: true, + }, + { + value: 'zh-Hans', + name: '简体中文', + prompt_name: 'Chinese Simplified', + example: '你好,Dify!', + supported: true, + }, + { + value: 'zh-Hant', + name: '繁體中文', + prompt_name: 'Chinese Traditional', + example: '你好,Dify!', + supported: true, + }, + { + value: 'pt-BR', + name: 'Português (Brasil)', + prompt_name: 'Portuguese', + example: 'Olá, Dify!', + supported: true, + }, + { + value: 'es-ES', + name: 'Español (España)', + prompt_name: 'Spanish', + example: '¡Hola, Dify!', + supported: true, + }, + { + value: 'fr-FR', + name: 'Français (France)', + prompt_name: 'French', + example: 'Bonjour, Dify!', + supported: true, + }, + { + value: 'de-DE', + name: 'Deutsch (Deutschland)', + prompt_name: 'German', + example: 'Hallo, Dify!', + supported: true, + }, + { + value: 'ja-JP', + name: '日本語 (日本)', + prompt_name: 'Japanese', + example: 'こんにちは、Dify!', + supported: true, + }, + { + value: 'ko-KR', + name: '한국어 (대한민국)', + prompt_name: 'Korean', + example: '안녕하세요, Dify!', + supported: true, + }, + { + value: 'ru-RU', + name: 'Русский (Россия)', + prompt_name: 'Russian', + example: ' Привет, Dify!', + supported: true, + }, + { + value: 'it-IT', + name: 'Italiano (Italia)', + prompt_name: 'Italian', + example: 'Ciao, Dify!', + supported: true, + }, + { + value: 'th-TH', + name: 'ไทย (ประเทศไทย)', + prompt_name: 'Thai', + example: 'สวัสดี Dify!', + supported: true, + }, + { + value: 'uk-UA', + name: 'Українська (Україна)', + prompt_name: 'Ukrainian', + example: 'Привет, Dify!', + supported: true, + }, + { + value: 'vi-VN', + name: 'Tiếng Việt (Việt Nam)', + prompt_name: 'Vietnamese', + example: 'Xin chào, Dify!', + supported: true, + }, + { + value: 'ro-RO', + name: 'Română (România)', + prompt_name: 'Romanian', + example: 'Salut, Dify!', + supported: true, + }, + { + value: 'pl-PL', + name: 'Polski (Polish)', + prompt_name: 'Polish', + example: 'Cześć, Dify!', + supported: true, + }, + { + value: 'hi-IN', + name: 'Hindi (India)', + prompt_name: 'Hindi', + example: 'नमस्ते, Dify!', + supported: true, + }, + { + value: 'tr-TR', + name: 'Türkçe', + prompt_name: 'Türkçe', + example: 'Selam!', + supported: true, + }, + { + value: 'fa-IR', + name: 'Farsi (Iran)', + prompt_name: 'Farsi', + example: 'سلام, دیفای!', + supported: true, + }, + { + value: 'sl-SI', + name: 'Slovensko (Slovenija)', + prompt_name: 'Slovensko', + example: 'Zdravo, Dify!', + supported: true, + }, + { + value: 'id-ID', + name: 'Bahasa Indonesia', + prompt_name: 'Indonesian', + example: 'Halo, Dify!', + supported: true, + }, + { + value: 'ar-TN', + name: 'العربية (تونس)', + prompt_name: 'Tunisian Arabic', + example: 'مرحبا، Dify!', + supported: true, + }, + ], +} as const + +export default data diff --git a/web/i18n-config/server.ts b/web/i18n-config/server.ts index c4e008cf84..75a5f3943b 100644 --- a/web/i18n-config/server.ts +++ b/web/i18n-config/server.ts @@ -1,19 +1,20 @@ import type { Locale } from '.' +import type { KeyPrefix, Namespace } from './i18next-config' import { match } from '@formatjs/intl-localematcher' import { createInstance } from 'i18next' - import resourcesToBackend from 'i18next-resources-to-backend' +import { camelCase } from 'lodash-es' import Negotiator from 'negotiator' import { cookies, headers } from 'next/headers' import { initReactI18next } from 'react-i18next/initReactI18next' import { i18n } from '.' // https://locize.com/blog/next-13-app-dir-i18n/ -const initI18next = async (lng: Locale, ns: string) => { +const initI18next = async (lng: Locale, ns: Namespace) => { const i18nInstance = createInstance() await i18nInstance .use(initReactI18next) - .use(resourcesToBackend((language: string, namespace: string) => import(`../i18n/${language}/${namespace}.ts`))) + .use(resourcesToBackend((language: Locale, namespace: Namespace) => import(`../i18n/${language}/${namespace}.ts`))) .init({ lng: lng === 'zh-Hans' ? 'zh-Hans' : lng, ns, @@ -22,10 +23,10 @@ const initI18next = async (lng: Locale, ns: string) => { return i18nInstance } -export async function useTranslation(lng: Locale, ns = '', options: Record = {}) { +export async function getTranslation(lng: Locale, ns: Namespace) { const i18nextInstance = await initI18next(lng, ns) return { - t: i18nextInstance.getFixedT(lng, ns, options.keyPrefix), + t: i18nextInstance.getFixedT(lng, 'translation', camelCase(ns) as KeyPrefix), i18n: i18nextInstance, } } diff --git a/web/i18n/en-US/app-api.ts b/web/i18n/en-US/app-api.ts index 1fba63c977..17f1a06782 100644 --- a/web/i18n/en-US/app-api.ts +++ b/web/i18n/en-US/app-api.ts @@ -79,6 +79,7 @@ const translation = { pathParams: 'Path Params', query: 'Query', toc: 'Contents', + noContent: 'No content', }, } diff --git a/web/i18n/en-US/app-debug.ts b/web/i18n/en-US/app-debug.ts index 815c6d9aeb..5604de3dd1 100644 --- a/web/i18n/en-US/app-debug.ts +++ b/web/i18n/en-US/app-debug.ts @@ -32,6 +32,9 @@ const translation = { cancelDisagree: 'Cancel dislike', userAction: 'User ', }, + code: { + instruction: 'Instruction', + }, notSetAPIKey: { title: 'LLM provider key has not been set', trailFinished: 'Trail finished', diff --git a/web/i18n/en-US/app.ts b/web/i18n/en-US/app.ts index 1f41d3601e..45ebd61aec 100644 --- a/web/i18n/en-US/app.ts +++ b/web/i18n/en-US/app.ts @@ -1,4 +1,9 @@ const translation = { + theme: { + switchDark: 'Switch to dark theme', + switchLight: 'Switch to light theme', + }, + appNamePlaceholder: 'Give your app a name', createApp: 'CREATE APP', types: { all: 'All', @@ -298,6 +303,7 @@ const translation = { commandHint: 'Type @ to browse by category', slashHint: 'Type / to see all available commands', actions: { + slashTitle: 'Commands', searchApplications: 'Search Applications', searchApplicationsDesc: 'Search and navigate to your applications', searchPlugins: 'Search Plugins', diff --git a/web/i18n/en-US/common.ts b/web/i18n/en-US/common.ts index 92d24b1351..2e378afeda 100644 --- a/web/i18n/en-US/common.ts +++ b/web/i18n/en-US/common.ts @@ -1,4 +1,6 @@ const translation = { + loading: 'Loading', + error: 'Error', theme: { theme: 'Theme', light: 'light', @@ -71,6 +73,8 @@ const translation = { saveAndRegenerate: 'Save & Regenerate Child Chunks', view: 'View', viewMore: 'VIEW MORE', + back: 'Back', + imageDownloaded: 'Image downloaded', regenerate: 'Regenerate', submit: 'Submit', skip: 'Skip', @@ -252,6 +256,7 @@ const translation = { feedbackPlaceholder: 'Optional', editWorkspaceInfo: 'Edit Workspace Info', workspaceName: 'Workspace Name', + workspaceNamePlaceholder: 'Enter workspace name', workspaceIcon: 'Workspace Icon', changeEmail: { title: 'Change Email', @@ -515,6 +520,7 @@ const translation = { emptyProviderTip: 'Please install a model provider first.', auth: { unAuthorized: 'Unauthorized', + credentialRemoved: 'Credential removed', authRemoved: 'Auth removed', apiKeys: 'API Keys', addApiKey: 'Add API Key', diff --git a/web/i18n/en-US/dataset.ts b/web/i18n/en-US/dataset.ts index 6ffd312fff..ee1997f699 100644 --- a/web/i18n/en-US/dataset.ts +++ b/web/i18n/en-US/dataset.ts @@ -245,6 +245,7 @@ const translation = { button: 'Drag and drop file or folder, or', browse: 'Browse', tip: '{{supportTypes}} (Max {{batchCount}}, {{size}}MB each)', + fileSizeLimitExceeded: 'File size exceeds the {{size}}MB limit', }, } diff --git a/web/i18n/en-US/login.ts b/web/i18n/en-US/login.ts index dd923db217..3b0c8bbba1 100644 --- a/web/i18n/en-US/login.ts +++ b/web/i18n/en-US/login.ts @@ -64,6 +64,7 @@ const translation = { passwordInvalid: 'Password must contain letters and numbers, and the length must be greater than 8', registrationNotAllowed: 'Account not found. Please contact the system admin to register.', invalidEmailOrPassword: 'Invalid email or password.', + redirectUrlMissing: 'Redirect URL is missing', }, license: { tip: 'Before starting Dify Community Edition, read the GitHub', diff --git a/web/i18n/en-US/plugin-trigger.ts b/web/i18n/en-US/plugin-trigger.ts index aedd0c6225..f1d697e507 100644 --- a/web/i18n/en-US/plugin-trigger.ts +++ b/web/i18n/en-US/plugin-trigger.ts @@ -158,6 +158,7 @@ const translation = { }, errors: { createFailed: 'Failed to create subscription', + updateFailed: 'Failed to update subscription', verifyFailed: 'Failed to verify credentials', authFailed: 'Authorization failed', networkError: 'Network error, please try again', diff --git a/web/i18n/en-US/tools.ts b/web/i18n/en-US/tools.ts index 86c225c1b2..b2753a5721 100644 --- a/web/i18n/en-US/tools.ts +++ b/web/i18n/en-US/tools.ts @@ -14,6 +14,7 @@ const translation = { }, author: 'By', auth: { + unauthorized: 'Unauthorized', authorized: 'Authorized', setup: 'Set up authorization to use', setupModalTitle: 'Set Up Authorization', diff --git a/web/i18n/en-US/workflow.ts b/web/i18n/en-US/workflow.ts index a023ac2b91..2122c20aaa 100644 --- a/web/i18n/en-US/workflow.ts +++ b/web/i18n/en-US/workflow.ts @@ -1106,6 +1106,9 @@ const translation = { lastDay: 'Last day', lastDayTooltip: 'Not all months have 31 days. Use the \'last day\' option to select each month\'s final day.', mode: 'Mode', + modeVisual: 'Visual', + modeCron: 'Cron', + selectTime: 'Select time', timezone: 'Timezone', visualConfig: 'Visual Configuration', monthlyDay: 'Monthly Day', diff --git a/web/i18n/ja-JP/app-api.ts b/web/i18n/ja-JP/app-api.ts index e344ad04a9..35203e53e0 100644 --- a/web/i18n/ja-JP/app-api.ts +++ b/web/i18n/ja-JP/app-api.ts @@ -78,6 +78,7 @@ const translation = { pathParams: 'パスパラメータ', query: 'クエリ', toc: '内容', + noContent: 'コンテンツなし', }, regenerate: '再生', } diff --git a/web/i18n/ja-JP/app-debug.ts b/web/i18n/ja-JP/app-debug.ts index 77d991974f..9d1deb47eb 100644 --- a/web/i18n/ja-JP/app-debug.ts +++ b/web/i18n/ja-JP/app-debug.ts @@ -32,6 +32,9 @@ const translation = { cancelDisagree: 'いいえをキャンセル', userAction: 'ユーザー', }, + code: { + instruction: '指示', + }, notSetAPIKey: { title: 'LLM プロバイダーキーが設定されていません', trailFinished: 'トライアル終了', diff --git a/web/i18n/ja-JP/app.ts b/web/i18n/ja-JP/app.ts index f084fc3b8c..899405e8e7 100644 --- a/web/i18n/ja-JP/app.ts +++ b/web/i18n/ja-JP/app.ts @@ -1,4 +1,9 @@ const translation = { + theme: { + switchDark: 'ダークテーマに切り替え', + switchLight: 'ライトテーマに切り替え', + }, + appNamePlaceholder: 'アプリに名前を付ける', createApp: 'アプリを作成する', types: { all: '全て', @@ -295,6 +300,7 @@ const translation = { commandHint: '@ を入力してカテゴリ別に参照', slashHint: '/ を入力してすべてのコマンドを表示', actions: { + slashTitle: 'コマンド', searchApplications: 'アプリケーションを検索', searchApplicationsDesc: 'アプリケーションを検索してナビゲート', searchPlugins: 'プラグインを検索', diff --git a/web/i18n/ja-JP/common.ts b/web/i18n/ja-JP/common.ts index bde00cb66b..87d9fa1fb1 100644 --- a/web/i18n/ja-JP/common.ts +++ b/web/i18n/ja-JP/common.ts @@ -1,4 +1,6 @@ const translation = { + loading: '読み込み中', + error: 'エラー', theme: { theme: 'テーマ', light: '明るい', @@ -68,6 +70,8 @@ const translation = { selectAll: 'すべて選択', deSelectAll: 'すべて選択解除', now: '今', + back: '戻る', + imageDownloaded: '画像がダウンロードされました', config: 'コンフィグ', yes: 'はい', no: 'いいえ', @@ -248,6 +252,7 @@ const translation = { sendVerificationButton: '確認コードの送信', editWorkspaceInfo: 'ワークスペース情報を編集', workspaceName: 'ワークスペース名', + workspaceNamePlaceholder: 'ワークスペース名を入力', workspaceIcon: 'ワークスペースアイコン', changeEmail: { title: 'メールアドレスを変更', @@ -512,6 +517,7 @@ const translation = { authorizationError: '認証エラー', apiKeys: 'APIキー', unAuthorized: '無許可', + credentialRemoved: '認証情報が削除されました', configModel: 'モデルを構成する', addApiKey: 'APIキーを追加してください', addCredential: '認証情報を追加する', diff --git a/web/i18n/ja-JP/dataset.ts b/web/i18n/ja-JP/dataset.ts index a880dd4f5a..b6c4e1d40c 100644 --- a/web/i18n/ja-JP/dataset.ts +++ b/web/i18n/ja-JP/dataset.ts @@ -245,6 +245,7 @@ const translation = { button: 'ファイルまたはフォルダをドラッグアンドドロップ、または', browse: '閲覧', tip: '{{supportTypes}}(最大 {{batchCount}}、各 {{size}}MB)', + fileSizeLimitExceeded: 'ファイルサイズが {{size}}MB の制限を超えています', }, } diff --git a/web/i18n/ja-JP/login.ts b/web/i18n/ja-JP/login.ts index 7069315c9d..c9e0fe3e1e 100644 --- a/web/i18n/ja-JP/login.ts +++ b/web/i18n/ja-JP/login.ts @@ -57,6 +57,7 @@ const translation = { passwordInvalid: 'パスワードは文字と数字を含み、長さは 8 以上である必要があります', registrationNotAllowed: 'アカウントが見つかりません。登録するためにシステム管理者に連絡してください。', invalidEmailOrPassword: '無効なメールアドレスまたはパスワードです。', + redirectUrlMissing: 'リダイレクト URL が見つかりません', }, license: { tip: 'GitHub のオープンソースライセンスを確認してから、Dify Community Edition を開始してください。', diff --git a/web/i18n/ja-JP/plugin-trigger.ts b/web/i18n/ja-JP/plugin-trigger.ts index c7453cff42..7dbd861909 100644 --- a/web/i18n/ja-JP/plugin-trigger.ts +++ b/web/i18n/ja-JP/plugin-trigger.ts @@ -165,6 +165,7 @@ const translation = { verifyFailed: '認証情報の検証に失敗しました', authFailed: '認証に失敗しました', networkError: 'ネットワークエラーです。再試行してください', + updateFailed: 'サブスクリプションの更新に失敗しました', }, }, events: { diff --git a/web/i18n/ja-JP/tools.ts b/web/i18n/ja-JP/tools.ts index 30f623575f..41dc30ac30 100644 --- a/web/i18n/ja-JP/tools.ts +++ b/web/i18n/ja-JP/tools.ts @@ -15,6 +15,7 @@ const translation = { author: '著者:', auth: { authorized: '認証済み', + unauthorized: '未認証', setup: '使用するための認証を設定する', setupModalTitle: '認証の設定', setupModalTitleDescription: '資格情報を構成した後、ワークスペース内のすべてのメンバーがアプリケーションのオーケストレーション時にこのツールを使用できます。', diff --git a/web/i18n/ja-JP/workflow.ts b/web/i18n/ja-JP/workflow.ts index 7f4e7a3009..24f05d6c31 100644 --- a/web/i18n/ja-JP/workflow.ts +++ b/web/i18n/ja-JP/workflow.ts @@ -1062,6 +1062,9 @@ const translation = { useVisualPicker: 'ビジュアル設定を使用', nodeTitle: 'スケジュールトリガー', mode: 'モード', + modeVisual: 'ビジュアル', + modeCron: 'Cron', + selectTime: '時間を選択', timezone: 'タイムゾーン', visualConfig: 'ビジュアル設定', monthlyDay: '月の日', diff --git a/web/i18n/zh-Hans/app-api.ts b/web/i18n/zh-Hans/app-api.ts index 4fe97f8231..70219e0cc6 100644 --- a/web/i18n/zh-Hans/app-api.ts +++ b/web/i18n/zh-Hans/app-api.ts @@ -79,6 +79,7 @@ const translation = { pathParams: 'Path Params', query: 'Query', toc: '目录', + noContent: '暂无内容', }, } diff --git a/web/i18n/zh-Hans/app-debug.ts b/web/i18n/zh-Hans/app-debug.ts index 33f563af99..e3df0ea9ea 100644 --- a/web/i18n/zh-Hans/app-debug.ts +++ b/web/i18n/zh-Hans/app-debug.ts @@ -32,6 +32,9 @@ const translation = { cancelDisagree: '取消反对', userAction: '用户表示', }, + code: { + instruction: '指令', + }, notSetAPIKey: { title: 'LLM 提供者的密钥未设置', trailFinished: '试用已结束', diff --git a/web/i18n/zh-Hans/app.ts b/web/i18n/zh-Hans/app.ts index 517c41de10..71edaa1629 100644 --- a/web/i18n/zh-Hans/app.ts +++ b/web/i18n/zh-Hans/app.ts @@ -1,4 +1,9 @@ const translation = { + theme: { + switchDark: '切换至深色主题', + switchLight: '切换至浅色主题', + }, + appNamePlaceholder: '给你的应用起个名字', createApp: '创建应用', types: { all: '全部', @@ -297,6 +302,7 @@ const translation = { commandHint: '输入 @ 按类别浏览', slashHint: '输入 / 查看所有可用命令', actions: { + slashTitle: '命令', searchApplications: '搜索应用程序', searchApplicationsDesc: '搜索并导航到您的应用程序', searchPlugins: '搜索插件', diff --git a/web/i18n/zh-Hans/common.ts b/web/i18n/zh-Hans/common.ts index bd0e0e3ba4..977ffe1919 100644 --- a/web/i18n/zh-Hans/common.ts +++ b/web/i18n/zh-Hans/common.ts @@ -1,4 +1,6 @@ const translation = { + loading: '加载中', + error: '错误', theme: { theme: '主题', light: '浅色', @@ -78,6 +80,8 @@ const translation = { selectAll: '全选', deSelectAll: '取消全选', now: '现在', + back: '返回', + imageDownloaded: '图片已下载', }, errorMsg: { fieldRequired: '{{field}} 为必填项', @@ -252,6 +256,7 @@ const translation = { feedbackPlaceholder: '选填', editWorkspaceInfo: '编辑工作空间信息', workspaceName: '工作空间名称', + workspaceNamePlaceholder: '输入工作空间名称', workspaceIcon: '工作空间图标', changeEmail: { title: '更改邮箱', @@ -509,6 +514,7 @@ const translation = { emptyProviderTip: '请安装模型供应商。', auth: { unAuthorized: '未授权', + credentialRemoved: '凭据已移除', authRemoved: '授权已移除', apiKeys: 'API 密钥', addApiKey: '添加 API 密钥', diff --git a/web/i18n/zh-Hans/dataset.ts b/web/i18n/zh-Hans/dataset.ts index 7399604762..781fb5aa94 100644 --- a/web/i18n/zh-Hans/dataset.ts +++ b/web/i18n/zh-Hans/dataset.ts @@ -245,6 +245,7 @@ const translation = { tip: '支持 {{supportTypes}} (最多 {{batchCount}} 个,每个大小不超过 {{size}}MB)', button: '拖拽文件或文件夹,或', browse: '浏览', + fileSizeLimitExceeded: '文件大小超过 {{size}}MB 限制', }, } diff --git a/web/i18n/zh-Hans/login.ts b/web/i18n/zh-Hans/login.ts index 13a75eaaaa..d79005fbdd 100644 --- a/web/i18n/zh-Hans/login.ts +++ b/web/i18n/zh-Hans/login.ts @@ -64,6 +64,7 @@ const translation = { passwordLengthInValid: '密码必须至少为 8 个字符', registrationNotAllowed: '账户不存在,请联系系统管理员注册账户', invalidEmailOrPassword: '邮箱或密码错误', + redirectUrlMissing: '重定向 URL 缺失', }, license: { tip: '启动 Dify 社区版之前,请阅读 GitHub 上的', diff --git a/web/i18n/zh-Hans/plugin-trigger.ts b/web/i18n/zh-Hans/plugin-trigger.ts index 304cdd47bd..4f31f517eb 100644 --- a/web/i18n/zh-Hans/plugin-trigger.ts +++ b/web/i18n/zh-Hans/plugin-trigger.ts @@ -161,6 +161,7 @@ const translation = { verifyFailed: '验证凭据失败', authFailed: '授权失败', networkError: '网络错误,请重试', + updateFailed: '更新订阅失败', }, }, events: { diff --git a/web/i18n/zh-Hans/tools.ts b/web/i18n/zh-Hans/tools.ts index 624fbb241a..7893a66f66 100644 --- a/web/i18n/zh-Hans/tools.ts +++ b/web/i18n/zh-Hans/tools.ts @@ -15,6 +15,7 @@ const translation = { author: '作者', auth: { authorized: '已授权', + unauthorized: '未授权', setup: '要使用请先授权', setupModalTitle: '设置授权', setupModalTitleDescription: '配置凭据后,工作区中的所有成员都可以在编排应用程序时使用此工具。', diff --git a/web/i18n/zh-Hans/workflow.ts b/web/i18n/zh-Hans/workflow.ts index fd86292252..a6daa56667 100644 --- a/web/i18n/zh-Hans/workflow.ts +++ b/web/i18n/zh-Hans/workflow.ts @@ -1062,6 +1062,9 @@ const translation = { days: '天', notConfigured: '未配置', mode: '模式', + modeVisual: '可视化', + modeCron: 'Cron', + selectTime: '选择时间', timezone: '时区', visualConfig: '可视化配置', monthlyDay: '月份日期', diff --git a/web/package.json b/web/package.json index baecd2c5c7..f3cc095811 100644 --- a/web/package.json +++ b/web/package.json @@ -33,10 +33,8 @@ "prepare": "cd ../ && node -e \"if (process.env.NODE_ENV !== 'production'){process.exit(1)} \" || husky ./web/.husky", "gen-icons": "node ./app/components/base/icons/script.mjs", "uglify-embed": "node ./bin/uglify-embed", - "check-i18n": "node ./i18n-config/check-i18n.js", - "auto-gen-i18n": "node ./i18n-config/auto-gen-i18n.js", - "gen:i18n-types": "node ./i18n-config/generate-i18n-types.js", - "check:i18n-types": "node ./i18n-config/check-i18n-sync.js", + "check-i18n": "tsx ./i18n-config/check-i18n.js", + "auto-gen-i18n": "tsx ./i18n-config/auto-gen-i18n.js", "test": "vitest run", "test:coverage": "vitest run --coverage", "test:watch": "vitest --watch", @@ -212,7 +210,7 @@ "sass": "^1.93.2", "storybook": "9.1.17", "tailwindcss": "^3.4.18", - "ts-node": "^10.9.2", + "tsx": "^4.21.0", "typescript": "^5.9.3", "uglify-js": "^3.19.3", "vite": "^7.3.0", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 95d35c24d8..24e710534f 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -544,9 +544,9 @@ importers: tailwindcss: specifier: ^3.4.18 version: 3.4.18(tsx@4.21.0)(yaml@2.8.2) - ts-node: - specifier: ^10.9.2 - version: 10.9.2(@types/node@18.15.0)(typescript@5.9.3) + tsx: + specifier: ^4.21.0 + version: 4.21.0 typescript: specifier: ^5.9.3 version: 5.9.3 @@ -1360,10 +1360,6 @@ packages: '@code-inspector/webpack@1.2.9': resolution: {integrity: sha512-9YEykVrOIc0zMV7pyTyZhCprjScjn6gPPmxb4/OQXKCrP2fAm+NB188rg0s95e4sM7U3qRUpPA4NUH5F7Ogo+g==} - '@cspotcode/source-map-support@0.8.1': - resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} - engines: {node: '>=12'} - '@csstools/color-helpers@5.1.0': resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} engines: {node: '>=18'} @@ -2175,9 +2171,6 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - '@jridgewell/trace-mapping@0.3.9': - resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} - '@lexical/clipboard@0.38.2': resolution: {integrity: sha512-dDShUplCu8/o6BB9ousr3uFZ9bltR+HtleF/Tl8FXFNPpZ4AXhbLKUoJuucRuIr+zqT7RxEv/3M6pk/HEoE6NQ==} @@ -3405,18 +3398,6 @@ packages: peerDependencies: '@testing-library/dom': '>=7.21.4' - '@tsconfig/node10@1.0.12': - resolution: {integrity: sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==} - - '@tsconfig/node12@1.0.11': - resolution: {integrity: sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==} - - '@tsconfig/node14@1.0.3': - resolution: {integrity: sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==} - - '@tsconfig/node16@1.0.4': - resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==} - '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} @@ -4094,9 +4075,6 @@ packages: resolution: {integrity: sha512-ixiS0nLNNG5jNQzgZJNoUpBKdo9yTYZMGJ+QgT2jmjR7G7+QHRCc4v6LQ3NgE7EBJq+o0ams3waJwkrlBom8Ig==} engines: {node: '>=14'} - arg@4.1.3: - resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} - arg@5.0.2: resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} @@ -4624,9 +4602,6 @@ packages: create-hmac@1.1.7: resolution: {integrity: sha512-MJG9liiZ+ogc4TzUwuvbER1JRdgvUFSB5+VR/g5h82fGaIRWMWddtKBHi7/sVhfjQZ6SehlyhvQYrcYkaUIpLg==} - create-require@1.1.1: - resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} - cron-parser@5.4.0: resolution: {integrity: sha512-HxYB8vTvnQFx4dLsZpGRa0uHp6X3qIzS3ZJgJ9v6l/5TJMgeWQbLkR5yiJ5hOxGbc9+jCADDnydIe15ReLZnJA==} engines: {node: '>=18'} @@ -4936,10 +4911,6 @@ packages: resolution: {integrity: sha512-k1gCAXAsNgLwEL+Y8Wvl+M6oEFj5bgazfZULpS5CneoPPXRaCCW7dm+q21Ky2VEE5X+VeRDBVg1Pcvvsr4TtNQ==} engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} - diff@4.0.2: - resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==} - engines: {node: '>=0.3.1'} - diffie-hellman@5.0.3: resolution: {integrity: sha512-kqag/Nl+f3GwyK25fhUMYj81BUOrZ9IuJsjIcDE5icNM9FJHAVm3VcUDxdLPoQtTuUylWm6ZIknYJwwaPxsUzg==} @@ -6364,9 +6335,6 @@ packages: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} - make-error@1.3.6: - resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} - markdown-extensions@2.0.0: resolution: {integrity: sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q==} engines: {node: '>=16'} @@ -8159,20 +8127,6 @@ packages: ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} - ts-node@10.9.2: - resolution: {integrity: sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==} - hasBin: true - peerDependencies: - '@swc/core': '>=1.2.50' - '@swc/wasm': '>=1.2.50' - '@types/node': '*' - typescript: '>=2.7' - peerDependenciesMeta: - '@swc/core': - optional: true - '@swc/wasm': - optional: true - ts-pattern@5.9.0: resolution: {integrity: sha512-6s5V71mX8qBUmlgbrfL33xDUwO0fq48rxAu2LBE11WBeGdpCPOsXksQbZJHvHwhrd3QjUusd3mAOM5Gg0mFBLg==} @@ -8407,9 +8361,6 @@ packages: resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} hasBin: true - v8-compile-cache-lib@3.0.1: - resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} - vfile-location@5.0.3: resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==} @@ -8797,10 +8748,6 @@ packages: resolution: {integrity: sha512-OIDwaflOaq4wC6YlPBy2L6ceKeKuF7DeTxx+jPzv1FHn9tCZ0ZwSRnUBxD05E3yed46fv/FWJbvR+Ud7x0L7zw==} engines: {node: '>=16.0.0', npm: '>=8.0.0'} - yn@3.1.1: - resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==} - engines: {node: '>=6'} - yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -9954,10 +9901,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@cspotcode/source-map-support@0.8.1': - dependencies: - '@jridgewell/trace-mapping': 0.3.9 - '@csstools/color-helpers@5.1.0': {} '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': @@ -10600,11 +10543,6 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@jridgewell/trace-mapping@0.3.9': - dependencies: - '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.5 - '@lexical/clipboard@0.38.2': dependencies: '@lexical/html': 0.38.2 @@ -11923,14 +11861,6 @@ snapshots: dependencies: '@testing-library/dom': 10.4.1 - '@tsconfig/node10@1.0.12': {} - - '@tsconfig/node12@1.0.11': {} - - '@tsconfig/node14@1.0.3': {} - - '@tsconfig/node16@1.0.4': {} - '@tybys/wasm-util@0.10.1': dependencies: tslib: 2.8.1 @@ -12742,8 +12672,6 @@ snapshots: are-docs-informative@0.0.2: {} - arg@4.1.3: {} - arg@5.0.2: {} argparse@2.0.1: {} @@ -13291,8 +13219,6 @@ snapshots: safe-buffer: 5.2.1 sha.js: 2.4.12 - create-require@1.1.1: {} - cron-parser@5.4.0: dependencies: luxon: 3.7.2 @@ -13625,8 +13551,6 @@ snapshots: diff-sequences@27.5.1: {} - diff@4.0.2: {} - diffie-hellman@5.0.3: dependencies: bn.js: 4.12.2 @@ -15350,8 +15274,6 @@ snapshots: dependencies: semver: 7.7.3 - make-error@1.3.6: {} - markdown-extensions@2.0.0: {} markdown-table@3.0.4: {} @@ -17655,24 +17577,6 @@ snapshots: ts-interface-checker@0.1.13: {} - ts-node@10.9.2(@types/node@18.15.0)(typescript@5.9.3): - dependencies: - '@cspotcode/source-map-support': 0.8.1 - '@tsconfig/node10': 1.0.12 - '@tsconfig/node12': 1.0.11 - '@tsconfig/node14': 1.0.3 - '@tsconfig/node16': 1.0.4 - '@types/node': 18.15.0 - acorn: 8.15.0 - acorn-walk: 8.3.4 - arg: 4.1.3 - create-require: 1.1.1 - diff: 4.0.2 - make-error: 1.3.6 - typescript: 5.9.3 - v8-compile-cache-lib: 3.0.1 - yn: 3.1.1 - ts-pattern@5.9.0: {} tsconfck@3.1.6(typescript@5.9.3): @@ -17888,8 +17792,6 @@ snapshots: uuid@11.1.0: {} - v8-compile-cache-lib@3.0.1: {} - vfile-location@5.0.3: dependencies: '@types/unist': 3.0.3 @@ -18312,8 +18214,6 @@ snapshots: dependencies: lib0: 0.2.115 - yn@3.1.1: {} - yocto-queue@0.1.0: {} yocto-queue@1.2.2: {} diff --git a/web/types/i18n.d.ts b/web/types/i18n.d.ts index b5e5b39aa7..3e5b10674a 100644 --- a/web/types/i18n.d.ts +++ b/web/types/i18n.d.ts @@ -1,74 +1,8 @@ -// TypeScript type definitions for Dify's i18next configuration -// This file is auto-generated. Do not edit manually. -// To regenerate, run: pnpm run gen:i18n-types +import type { messagesEN } from '../i18n-config/i18next-config' import 'react-i18next' -// Extract types from translation files using typeof import pattern - -type AppAnnotationMessages = typeof import('../i18n/en-US/app-annotation').default -type AppApiMessages = typeof import('../i18n/en-US/app-api').default -type AppDebugMessages = typeof import('../i18n/en-US/app-debug').default -type AppLogMessages = typeof import('../i18n/en-US/app-log').default -type AppOverviewMessages = typeof import('../i18n/en-US/app-overview').default -type AppMessages = typeof import('../i18n/en-US/app').default -type BillingMessages = typeof import('../i18n/en-US/billing').default -type CommonMessages = typeof import('../i18n/en-US/common').default -type CustomMessages = typeof import('../i18n/en-US/custom').default -type DatasetCreationMessages = typeof import('../i18n/en-US/dataset-creation').default -type DatasetDocumentsMessages = typeof import('../i18n/en-US/dataset-documents').default -type DatasetHitTestingMessages = typeof import('../i18n/en-US/dataset-hit-testing').default -type DatasetPipelineMessages = typeof import('../i18n/en-US/dataset-pipeline').default -type DatasetSettingsMessages = typeof import('../i18n/en-US/dataset-settings').default -type DatasetMessages = typeof import('../i18n/en-US/dataset').default -type EducationMessages = typeof import('../i18n/en-US/education').default -type ExploreMessages = typeof import('../i18n/en-US/explore').default -type LayoutMessages = typeof import('../i18n/en-US/layout').default -type LoginMessages = typeof import('../i18n/en-US/login').default -type OauthMessages = typeof import('../i18n/en-US/oauth').default -type PipelineMessages = typeof import('../i18n/en-US/pipeline').default -type PluginTagsMessages = typeof import('../i18n/en-US/plugin-tags').default -type PluginTriggerMessages = typeof import('../i18n/en-US/plugin-trigger').default -type PluginMessages = typeof import('../i18n/en-US/plugin').default -type RegisterMessages = typeof import('../i18n/en-US/register').default -type RunLogMessages = typeof import('../i18n/en-US/run-log').default -type ShareMessages = typeof import('../i18n/en-US/share').default -type TimeMessages = typeof import('../i18n/en-US/time').default -type ToolsMessages = typeof import('../i18n/en-US/tools').default -type WorkflowMessages = typeof import('../i18n/en-US/workflow').default - // Complete type structure that matches i18next-config.ts camelCase conversion -export type Messages = { - appAnnotation: AppAnnotationMessages - appApi: AppApiMessages - appDebug: AppDebugMessages - appLog: AppLogMessages - appOverview: AppOverviewMessages - app: AppMessages - billing: BillingMessages - common: CommonMessages - custom: CustomMessages - datasetCreation: DatasetCreationMessages - datasetDocuments: DatasetDocumentsMessages - datasetHitTesting: DatasetHitTestingMessages - datasetPipeline: DatasetPipelineMessages - datasetSettings: DatasetSettingsMessages - dataset: DatasetMessages - education: EducationMessages - explore: ExploreMessages - layout: LayoutMessages - login: LoginMessages - oauth: OauthMessages - pipeline: PipelineMessages - pluginTags: PluginTagsMessages - pluginTrigger: PluginTriggerMessages - plugin: PluginMessages - register: RegisterMessages - runLog: RunLogMessages - share: ShareMessages - time: TimeMessages - tools: ToolsMessages - workflow: WorkflowMessages -} +export type Messages = typeof messagesEN // Utility type to flatten nested object keys into dot notation type FlattenKeys = T extends object @@ -81,19 +15,9 @@ type FlattenKeys = T extends object export type ValidTranslationKeys = FlattenKeys -// Extend react-i18next with Dify's type structure -declare module 'react-i18next' { - type CustomTypeOptions = { - defaultNS: 'translation' - resources: { - translation: Messages - } - } -} - -// Extend i18next for complete type safety declare module 'i18next' { - type CustomTypeOptions = { + // eslint-disable-next-line ts/consistent-type-definitions + interface CustomTypeOptions { defaultNS: 'translation' resources: { translation: Messages diff --git a/web/utils/format.ts b/web/utils/format.ts index a2c3ef9751..d087d690a2 100644 --- a/web/utils/format.ts +++ b/web/utils/format.ts @@ -1,5 +1,6 @@ import type { Dayjs } from 'dayjs' import type { Locale } from '@/i18n-config' +import { localeMap } from '@/i18n-config/language' import 'dayjs/locale/de' import 'dayjs/locale/es' import 'dayjs/locale/fa' @@ -21,30 +22,6 @@ import 'dayjs/locale/vi' import 'dayjs/locale/zh-cn' import 'dayjs/locale/zh-tw' -const localeMap: Record = { - 'en-US': 'en', - 'zh-Hans': 'zh-cn', - 'zh-Hant': 'zh-tw', - 'pt-BR': 'pt-br', - 'es-ES': 'es', - 'fr-FR': 'fr', - 'de-DE': 'de', - 'ja-JP': 'ja', - 'ko-KR': 'ko', - 'ru-RU': 'ru', - 'it-IT': 'it', - 'th-TH': 'th', - 'id-ID': 'id', - 'uk-UA': 'uk', - 'vi-VN': 'vi', - 'ro-RO': 'ro', - 'pl-PL': 'pl', - 'hi-IN': 'hi', - 'tr-TR': 'tr', - 'fa-IR': 'fa', - 'sl-SI': 'sl', -} - /** * Formats a number with comma separators. * @example formatNumber(1234567) will return '1,234,567' @@ -149,6 +126,6 @@ export const formatNumberAbbreviated = (num: number) => { } } -export const formatToLocalTime = (time: Dayjs, local: string, format: string) => { +export const formatToLocalTime = (time: Dayjs, local: Locale, format: string) => { return time.locale(localeMap[local] ?? 'en').format(format) } From 18d69775ef6750e407409973ca4412e763bb214e Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Wed, 24 Dec 2025 17:03:43 +0800 Subject: [PATCH 05/11] refactor(web): migrate explore app lists from useSWR to TanStack Query (#30076) --- .../app/create-app-dialog/app-list/index.tsx | 21 ++---- .../explore/app-list/index.spec.tsx | 22 +++--- web/app/components/explore/app-list/index.tsx | 21 ++---- .../components/workflow-header/index.spec.tsx | 67 +++++++++++++------ web/service/explore.ts | 14 +--- web/service/use-explore.ts | 27 +++++++- 6 files changed, 91 insertions(+), 81 deletions(-) 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 df54de2ff1..ee64478141 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 @@ -8,7 +8,6 @@ import { useRouter } from 'next/navigation' import * as React from 'react' import { useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' -import useSWR from 'swr' import { useContext } from 'use-context-selector' import AppTypeSelector from '@/app/components/app/type-selector' import { trackEvent } from '@/app/components/base/amplitude' @@ -24,7 +23,8 @@ import ExploreContext from '@/context/explore-context' import { useTabSearchParams } from '@/hooks/use-tab-searchparams' import { DSLImportMode } from '@/models/app' import { importDSL } from '@/service/apps' -import { fetchAppDetail, fetchAppList } from '@/service/explore' +import { fetchAppDetail } from '@/service/explore' +import { exploreAppListInitialData, useExploreAppList } from '@/service/use-explore' import { AppModeEnum } from '@/types/app' import { getRedirection } from '@/utils/app-redirection' import { cn } from '@/utils/classnames' @@ -70,21 +70,8 @@ const Apps = ({ }) const { - data: { categories, allList }, - } = useSWR( - ['/explore/apps'], - () => - fetchAppList().then(({ categories, recommended_apps }) => ({ - categories, - allList: recommended_apps.sort((a, b) => a.position - b.position), - })), - { - fallbackData: { - categories: [], - allList: [], - }, - }, - ) + data: { categories, allList } = exploreAppListInitialData, + } = useExploreAppList() const filteredList = useMemo(() => { const filteredByCategory = allList.filter((item) => { diff --git a/web/app/components/explore/app-list/index.spec.tsx b/web/app/components/explore/app-list/index.spec.tsx index e73fcdf0ad..ebf2c9c075 100644 --- a/web/app/components/explore/app-list/index.spec.tsx +++ b/web/app/components/explore/app-list/index.spec.tsx @@ -10,7 +10,7 @@ import AppList from './index' const allCategoriesEn = 'explore.apps.allCategories:{"lng":"en"}' let mockTabValue = allCategoriesEn const mockSetTab = vi.fn() -let mockSWRData: { categories: string[], allList: App[] } = { categories: [], allList: [] } +let mockExploreData: { categories: string[], allList: App[] } = { categories: [], allList: [] } const mockHandleImportDSL = vi.fn() const mockHandleImportDSLConfirm = vi.fn() @@ -33,9 +33,9 @@ vi.mock('ahooks', async () => { } }) -vi.mock('swr', () => ({ - __esModule: true, - default: () => ({ data: mockSWRData }), +vi.mock('@/service/use-explore', () => ({ + exploreAppListInitialData: { categories: [], allList: [] }, + useExploreAppList: () => ({ data: mockExploreData }), })) vi.mock('@/service/explore', () => ({ @@ -135,14 +135,14 @@ describe('AppList', () => { beforeEach(() => { vi.clearAllMocks() mockTabValue = allCategoriesEn - mockSWRData = { categories: [], allList: [] } + mockExploreData = { categories: [], allList: [] } }) // Rendering: show loading when categories are not ready. describe('Rendering', () => { it('should render loading when categories are empty', () => { // Arrange - mockSWRData = { categories: [], allList: [] } + mockExploreData = { categories: [], allList: [] } // Act renderWithContext() @@ -153,7 +153,7 @@ describe('AppList', () => { it('should render app cards when data is available', () => { // Arrange - mockSWRData = { + mockExploreData = { categories: ['Writing', 'Translate'], allList: [createApp(), createApp({ app_id: 'app-2', app: { ...createApp().app, name: 'Beta' }, category: 'Translate' })], } @@ -172,7 +172,7 @@ describe('AppList', () => { it('should filter apps by selected category', () => { // Arrange mockTabValue = 'Writing' - mockSWRData = { + mockExploreData = { categories: ['Writing', 'Translate'], allList: [createApp(), createApp({ app_id: 'app-2', app: { ...createApp().app, name: 'Beta' }, category: 'Translate' })], } @@ -190,7 +190,7 @@ describe('AppList', () => { describe('User Interactions', () => { it('should filter apps by search keywords', async () => { // Arrange - mockSWRData = { + mockExploreData = { categories: ['Writing'], allList: [createApp(), createApp({ app_id: 'app-2', app: { ...createApp().app, name: 'Gamma' } })], } @@ -210,7 +210,7 @@ describe('AppList', () => { it('should handle create flow and confirm DSL when pending', async () => { // Arrange const onSuccess = vi.fn() - mockSWRData = { + mockExploreData = { categories: ['Writing'], allList: [createApp()], }; @@ -246,7 +246,7 @@ describe('AppList', () => { describe('Edge Cases', () => { it('should reset search results when clear icon is clicked', async () => { // Arrange - mockSWRData = { + mockExploreData = { categories: ['Writing'], allList: [createApp(), createApp({ app_id: 'app-2', app: { ...createApp().app, name: 'Gamma' } })], } diff --git a/web/app/components/explore/app-list/index.tsx b/web/app/components/explore/app-list/index.tsx index 585c4e60c1..244a116e36 100644 --- a/web/app/components/explore/app-list/index.tsx +++ b/web/app/components/explore/app-list/index.tsx @@ -6,7 +6,6 @@ import { useDebounceFn } from 'ahooks' import * as React from 'react' import { useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' -import useSWR from 'swr' import { useContext } from 'use-context-selector' import DSLConfirmModal from '@/app/components/app/create-from-dsl-modal/dsl-confirm-modal' import Input from '@/app/components/base/input' @@ -20,7 +19,8 @@ import { useTabSearchParams } from '@/hooks/use-tab-searchparams' import { DSLImportMode, } from '@/models/app' -import { fetchAppDetail, fetchAppList } from '@/service/explore' +import { fetchAppDetail } from '@/service/explore' +import { exploreAppListInitialData, useExploreAppList } from '@/service/use-explore' import { cn } from '@/utils/classnames' import s from './style.module.css' @@ -58,21 +58,8 @@ const Apps = ({ }) const { - data: { categories, allList }, - } = useSWR( - ['/explore/apps'], - () => - fetchAppList().then(({ categories, recommended_apps }) => ({ - categories, - allList: recommended_apps.sort((a, b) => a.position - b.position), - })), - { - fallbackData: { - categories: [], - allList: [], - }, - }, - ) + data: { categories, allList } = exploreAppListInitialData, + } = useExploreAppList() const filteredList = allList.filter(item => currCategory === allCategoriesEn || item.category === currCategory) diff --git a/web/app/components/workflow-app/components/workflow-header/index.spec.tsx b/web/app/components/workflow-app/components/workflow-header/index.spec.tsx index 8eee878cd9..87d7fb30e7 100644 --- a/web/app/components/workflow-app/components/workflow-header/index.spec.tsx +++ b/web/app/components/workflow-app/components/workflow-header/index.spec.tsx @@ -1,6 +1,6 @@ import type { HeaderProps } from '@/app/components/workflow/header' import type { App } from '@/types/app' -import { render, screen } from '@testing-library/react' +import { fireEvent, render, screen } from '@testing-library/react' import { AppModeEnum } from '@/types/app' import WorkflowHeader from './index' @@ -9,8 +9,47 @@ const mockSetCurrentLogItem = vi.fn() const mockSetShowMessageLogModal = vi.fn() const mockResetWorkflowVersionHistory = vi.fn() +const createMockApp = (overrides: Partial = {}): App => ({ + id: 'app-id', + name: 'Workflow App', + description: 'Workflow app description', + author_name: 'Workflow app author', + icon_type: 'emoji', + icon: 'app-icon', + icon_background: '#FFFFFF', + icon_url: null, + use_icon_as_answer_icon: false, + mode: AppModeEnum.COMPLETION, + enable_site: true, + enable_api: true, + api_rpm: 60, + api_rph: 3600, + is_demo: false, + model_config: {} as App['model_config'], + app_model_config: {} as App['app_model_config'], + created_at: 0, + updated_at: 0, + site: { + access_token: 'token', + app_base_url: 'https://example.com', + } as App['site'], + api_base_url: 'https://api.example.com', + tags: [], + access_mode: 'public_access' as App['access_mode'], + ...overrides, +}) + let appDetail: App +const mockAppStore = (overrides: Partial = {}) => { + appDetail = createMockApp(overrides) + mockUseAppStoreSelector.mockImplementation(selector => selector({ + appDetail, + setCurrentLogItem: mockSetCurrentLogItem, + setShowMessageLogModal: mockSetShowMessageLogModal, + })) +} + vi.mock('@/app/components/app/store', () => ({ __esModule: true, useStore: (selector: (state: { appDetail?: App, setCurrentLogItem: typeof mockSetCurrentLogItem, setShowMessageLogModal: typeof mockSetShowMessageLogModal }) => unknown) => mockUseAppStoreSelector(selector), @@ -60,13 +99,7 @@ vi.mock('@/service/use-workflow', () => ({ describe('WorkflowHeader', () => { beforeEach(() => { vi.clearAllMocks() - appDetail = { id: 'app-id', mode: AppModeEnum.COMPLETION } as unknown as App - - mockUseAppStoreSelector.mockImplementation(selector => selector({ - appDetail, - setCurrentLogItem: mockSetCurrentLogItem, - setShowMessageLogModal: mockSetShowMessageLogModal, - })) + mockAppStore() }) // Verifies the wrapper renders the workflow header shell. @@ -84,12 +117,7 @@ describe('WorkflowHeader', () => { describe('Props', () => { it('should configure preview mode when app is in advanced chat mode', () => { // Arrange - appDetail = { id: 'app-id', mode: AppModeEnum.ADVANCED_CHAT } as unknown as App - mockUseAppStoreSelector.mockImplementation(selector => selector({ - appDetail, - setCurrentLogItem: mockSetCurrentLogItem, - setShowMessageLogModal: mockSetShowMessageLogModal, - })) + mockAppStore({ mode: AppModeEnum.ADVANCED_CHAT }) // Act render() @@ -104,12 +132,7 @@ describe('WorkflowHeader', () => { it('should configure run mode when app is not in advanced chat mode', () => { // Arrange - appDetail = { id: 'app-id', mode: AppModeEnum.COMPLETION } as unknown as App - mockUseAppStoreSelector.mockImplementation(selector => selector({ - appDetail, - setCurrentLogItem: mockSetCurrentLogItem, - setShowMessageLogModal: mockSetShowMessageLogModal, - })) + mockAppStore({ mode: AppModeEnum.COMPLETION }) // Act render() @@ -130,7 +153,7 @@ describe('WorkflowHeader', () => { render() // Act - screen.getByRole('button', { name: 'clear-history' }).click() + fireEvent.click(screen.getByRole('button', { name: /clear-history/i })) // Assert expect(mockSetCurrentLogItem).toHaveBeenCalledWith() @@ -145,7 +168,7 @@ describe('WorkflowHeader', () => { render() // Assert - screen.getByRole('button', { name: 'restore-settled' }).click() + fireEvent.click(screen.getByRole('button', { name: /restore-settled/i })) expect(mockResetWorkflowVersionHistory).toHaveBeenCalled() }) }) diff --git a/web/service/explore.ts b/web/service/explore.ts index 70d5de37f2..b4056da4ab 100644 --- a/web/service/explore.ts +++ b/web/service/explore.ts @@ -1,6 +1,6 @@ import type { AccessMode } from '@/models/access-control' import type { App, AppCategory } from '@/models/explore' -import { del, get, patch, post } from './base' +import { del, get, patch } from './base' export const fetchAppList = () => { return get<{ @@ -17,14 +17,6 @@ export const fetchInstalledAppList = (app_id?: string | null) => { return get(`/installed-apps${app_id ? `?app_id=${app_id}` : ''}`) } -export const installApp = (id: string) => { - return post('/installed-apps', { - body: { - app_id: id, - }, - }) -} - export const uninstallApp = (id: string) => { return del(`/installed-apps/${id}`) } @@ -37,10 +29,6 @@ export const updatePinStatus = (id: string, isPinned: boolean) => { }) } -export const getToolProviders = () => { - return get('/workspaces/current/tool-providers') -} - export const getAppAccessModeByAppId = (appId: string) => { return get<{ accessMode: AccessMode }>(`/enterprise/webapp/app/access-mode?appId=${appId}`) } diff --git a/web/service/use-explore.ts b/web/service/use-explore.ts index 6e57599b69..8bda877908 100644 --- a/web/service/use-explore.ts +++ b/web/service/use-explore.ts @@ -1,11 +1,36 @@ +import type { App, AppCategory } from '@/models/explore' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useGlobalPublicStore } from '@/context/global-public-context' import { AccessMode } from '@/models/access-control' -import { fetchInstalledAppList, getAppAccessModeByAppId, uninstallApp, updatePinStatus } from './explore' +import { fetchAppList, fetchInstalledAppList, getAppAccessModeByAppId, uninstallApp, updatePinStatus } from './explore' import { fetchAppMeta, fetchAppParams } from './share' const NAME_SPACE = 'explore' +type ExploreAppListData = { + categories: AppCategory[] + allList: App[] +} + +export const exploreAppListInitialData: ExploreAppListData = { + categories: [], + allList: [], +} + +export const useExploreAppList = () => { + return useQuery({ + queryKey: [NAME_SPACE, 'appList'], + queryFn: async () => { + const { categories, recommended_apps } = await fetchAppList() + return { + categories, + allList: [...recommended_apps].sort((a, b) => a.position - b.position), + } + }, + placeholderData: exploreAppListInitialData, + }) +} + export const useGetInstalledApps = () => { return useQuery({ queryKey: [NAME_SPACE, 'installedApps'], From eb73f9a9b9fc9eee6be786d2bf5f41fc77e240c4 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Wed, 24 Dec 2025 17:17:36 +0800 Subject: [PATCH 06/11] chore: no template string in translation (#30101) --- web/i18n/de-DE/app-debug.ts | 4 +--- web/i18n/en-US/app-debug.ts | 4 +--- web/i18n/es-ES/app-debug.ts | 4 +--- web/i18n/fr-FR/app-debug.ts | 4 +--- web/i18n/it-IT/app-debug.ts | 4 +--- web/i18n/ja-JP/app-debug.ts | 4 +--- web/i18n/ko-KR/app-debug.ts | 4 +--- web/i18n/pl-PL/app-debug.ts | 4 +--- web/i18n/pt-BR/app-debug.ts | 4 +--- web/i18n/ro-RO/app-debug.ts | 4 +--- web/i18n/ru-RU/app-debug.ts | 4 +--- web/i18n/uk-UA/app-debug.ts | 4 +--- web/i18n/vi-VN/app-debug.ts | 4 +--- web/i18n/zh-Hans/app-debug.ts | 4 +--- web/i18n/zh-Hant/app-debug.ts | 4 +--- 15 files changed, 15 insertions(+), 45 deletions(-) diff --git a/web/i18n/de-DE/app-debug.ts b/web/i18n/de-DE/app-debug.ts index 07c9d4be99..c12a1f5291 100644 --- a/web/i18n/de-DE/app-debug.ts +++ b/web/i18n/de-DE/app-debug.ts @@ -357,9 +357,7 @@ const translation = { visionSettings: { title: 'Vision-Einstellungen', resolution: 'Auflösung', - resolutionTooltip: `Niedrige Auflösung ermöglicht es dem Modell, eine Bildversion mit niedriger Auflösung von 512 x 512 zu erhalten und das Bild mit einem Budget von 65 Tokens darzustellen. Dies ermöglicht schnellere Antworten des API und verbraucht weniger Eingabetokens für Anwendungsfälle, die kein hohes Detail benötigen. - \n - Hohe Auflösung ermöglicht zunächst, dass das Modell das Bild mit niedriger Auflösung sieht und dann detaillierte Ausschnitte von Eingabebildern als 512px Quadrate basierend auf der Größe des Eingabebildes erstellt. Jeder der detaillierten Ausschnitte verwendet das doppelte Token-Budget für insgesamt 129 Tokens.`, + resolutionTooltip: 'Niedrige Auflösung ermöglicht es dem Modell, eine Bildversion mit niedriger Auflösung von 512 x 512 zu erhalten und das Bild mit einem Budget von 65 Tokens darzustellen. Dies ermöglicht schnellere Antworten des API und verbraucht weniger Eingabetokens für Anwendungsfälle, die kein hohes Detail benötigen.\nHohe Auflösung ermöglicht zunächst, dass das Modell das Bild mit niedriger Auflösung sieht und dann detaillierte Ausschnitte von Eingabebildern als 512px Quadrate basierend auf der Größe des Eingabebildes erstellt. Jeder der detaillierten Ausschnitte verwendet das doppelte Token-Budget für insgesamt 129 Tokens.', high: 'Hoch', low: 'Niedrig', uploadMethod: 'Upload-Methode', diff --git a/web/i18n/en-US/app-debug.ts b/web/i18n/en-US/app-debug.ts index 5604de3dd1..9d69c36d83 100644 --- a/web/i18n/en-US/app-debug.ts +++ b/web/i18n/en-US/app-debug.ts @@ -448,9 +448,7 @@ const translation = { visionSettings: { title: 'Vision Settings', resolution: 'Resolution', - resolutionTooltip: `low res will allow model receive a low-res 512 x 512 version of the image, and represent the image with a budget of 65 tokens. This allows the API to return faster responses and consume fewer input tokens for use cases that do not require high detail. - \n - high res will first allows the model to see the low res image and then creates detailed crops of input images as 512px squares based on the input image size. Each of the detailed crops uses twice the token budget for a total of 129 tokens.`, + resolutionTooltip: 'low res will allow model receive a low-res 512 x 512 version of the image, and represent the image with a budget of 65 tokens. This allows the API to return faster responses and consume fewer input tokens for use cases that do not require high detail.\nhigh res will first allows the model to see the low res image and then creates detailed crops of input images as 512px squares based on the input image size. Each of the detailed crops uses twice the token budget for a total of 129 tokens.', high: 'High', low: 'Low', uploadMethod: 'Upload Method', diff --git a/web/i18n/es-ES/app-debug.ts b/web/i18n/es-ES/app-debug.ts index 892e718d32..4252037e1f 100644 --- a/web/i18n/es-ES/app-debug.ts +++ b/web/i18n/es-ES/app-debug.ts @@ -353,9 +353,7 @@ const translation = { visionSettings: { title: 'Configuraciones de Visión', resolution: 'Resolución', - resolutionTooltip: `Baja resolución permitirá que el modelo reciba una versión de baja resolución de 512 x 512 de la imagen, y represente la imagen con un presupuesto de 65 tokens. Esto permite que la API devuelva respuestas más rápidas y consuma menos tokens de entrada para casos de uso que no requieren alta detalle. - \n - Alta resolución permitirá primero que el modelo vea la imagen de baja resolución y luego crea recortes detallados de las imágenes de entrada como cuadrados de 512px basados en el tamaño de la imagen de entrada. Cada uno de los recortes detallados usa el doble del presupuesto de tokens para un total de 129 tokens.`, + resolutionTooltip: 'Baja resolución permitirá que el modelo reciba una versión de baja resolución de 512 x 512 de la imagen, y represente la imagen con un presupuesto de 65 tokens. Esto permite que la API devuelva respuestas más rápidas y consuma menos tokens de entrada para casos de uso que no requieren alta detalle.\nAlta resolución permitirá primero que el modelo vea la imagen de baja resolución y luego crea recortes detallados de las imágenes de entrada como cuadrados de 512px basados en el tamaño de la imagen de entrada. Cada uno de los recortes detallados usa el doble del presupuesto de tokens para un total de 129 tokens.', high: 'Alta', low: 'Baja', uploadMethod: 'Método de carga', diff --git a/web/i18n/fr-FR/app-debug.ts b/web/i18n/fr-FR/app-debug.ts index 26ebeca68d..2e65e681ca 100644 --- a/web/i18n/fr-FR/app-debug.ts +++ b/web/i18n/fr-FR/app-debug.ts @@ -357,9 +357,7 @@ const translation = { visionSettings: { title: 'Paramètres de Vision', resolution: 'Résolution', - resolutionTooltip: `low res will allow model receive a low-res 512 x 512 version of the image, and represent the image with a budget of 65 tokens. This allows the API to return faster responses and consume fewer input tokens for use cases that do not require high detail. - \n - high res will first allows the model to see the low res image and then creates detailed crops of input images as 512px squares based on the input image size. Each of the detailed crops uses twice the token budget for a total of 129 tokens.`, + resolutionTooltip: 'low res will allow model receive a low-res 512 x 512 version of the image, and represent the image with a budget of 65 tokens. This allows the API to return faster responses and consume fewer input tokens for use cases that do not require high detail.\nhigh res will first allows the model to see the low res image and then creates detailed crops of input images as 512px squares based on the input image size. Each of the detailed crops uses twice the token budget for a total of 129 tokens.', high: 'Élevé', low: 'Faible', uploadMethod: 'Méthode de Téléchargement', diff --git a/web/i18n/it-IT/app-debug.ts b/web/i18n/it-IT/app-debug.ts index ecae1f3a2e..e81a83a3dd 100644 --- a/web/i18n/it-IT/app-debug.ts +++ b/web/i18n/it-IT/app-debug.ts @@ -384,9 +384,7 @@ const translation = { visionSettings: { title: 'Impostazioni di visione', resolution: 'Risoluzione', - resolutionTooltip: `La bassa risoluzione permetterà al modello di ricevere una versione a bassa risoluzione 512 x 512 dell\\'immagine e di rappresentare l\\'immagine con un budget di 65 token. Questo permette all\\'API di restituire risposte più veloci e di consumare meno token di input per casi d\\'uso che non richiedono alta definizione. - \n - L\\'alta risoluzione permetterà al modello di vedere prima l\\'immagine a bassa risoluzione e poi di creare ritagli dettagliati delle immagini di input come quadrati 512px basati sulla dimensione dell\\'immagine di input. Ciascuno dei ritagli dettagliati utilizza il doppio del budget dei token per un totale di 129 token.`, + resolutionTooltip: 'La bassa risoluzione permetterà al modello di ricevere una versione a bassa risoluzione 512 x 512 dell\'immagine e di rappresentare l\'immagine con un budget di 65 token. Questo permette all\'API di restituire risposte più veloci e di consumare meno token di input per casi d\'uso che non richiedono alta definizione.\nL\'alta risoluzione permetterà al modello di vedere prima l\'immagine a bassa risoluzione e poi di creare ritagli dettagliati delle immagini di input come quadrati 512px basati sulla dimensione dell\'immagine di input. Ciascuno dei ritagli dettagliati utilizza il doppio del budget dei token per un totale di 129 token.', high: 'Alta', low: 'Bassa', uploadMethod: 'Metodo di caricamento', diff --git a/web/i18n/ja-JP/app-debug.ts b/web/i18n/ja-JP/app-debug.ts index 9d1deb47eb..06b47c1a47 100644 --- a/web/i18n/ja-JP/app-debug.ts +++ b/web/i18n/ja-JP/app-debug.ts @@ -442,9 +442,7 @@ const translation = { visionSettings: { title: 'ビジョン設定', resolution: '解像度', - resolutionTooltip: `低解像度では、モデルに低解像度の 512 x 512 バージョンの画像を受け取らせ、画像を 65 トークンの予算で表現します。これにより、API がより迅速な応答を返し、高い詳細が必要なユースケースでは入力トークンを消費します。 - \n - 高解像度では、まずモデルに低解像度の画像を見せ、その後、入力画像サイズに基づいて 512px の正方形の詳細なクロップを作成します。詳細なクロップごとに 129 トークンの予算を使用します。`, + resolutionTooltip: '低解像度では、モデルに低解像度の 512 x 512 バージョンの画像を受け取らせ、画像を 65 トークンの予算で表現します。これにより、API がより迅速な応答を返し、高い詳細が必要なユースケースでは入力トークンを消費します。\n高解像度では、まずモデルに低解像度の画像を見せ、その後、入力画像サイズに基づいて 512px の正方形の詳細なクロップを作成します。詳細なクロップごとに 129 トークンの予算を使用します。', high: '高', low: '低', uploadMethod: 'アップロード方法', diff --git a/web/i18n/ko-KR/app-debug.ts b/web/i18n/ko-KR/app-debug.ts index c9e048df0e..5258287285 100644 --- a/web/i18n/ko-KR/app-debug.ts +++ b/web/i18n/ko-KR/app-debug.ts @@ -353,9 +353,7 @@ const translation = { visionSettings: { title: '비전 설정', resolution: '해상도', - resolutionTooltip: `저해상도는 모델에게 512 x 512 해상도의 저해상도 이미지를 제공하여 65 토큰의 예산으로 이미지를 표현합니다. 이로 인해 API 는 더 빠른 응답을 제공하며 높은 세부 정보가 필요한 경우 토큰 소모를 늘립니다. - \n - 고해상도는 먼저 모델에게 저해상도 이미지를 보여주고, 그 후 입력 이미지 크기에 따라 512px 의 정사각형 세부 사진을 만듭니다. 각 세부 사진에 대해 129 토큰의 예산을 사용합니다.`, + resolutionTooltip: '저해상도는 모델에게 512 x 512 해상도의 저해상도 이미지를 제공하여 65 토큰의 예산으로 이미지를 표현합니다. 이로 인해 API 는 더 빠른 응답을 제공하며 높은 세부 정보가 필요한 경우 토큰 소모를 늘립니다.\n고해상도는 먼저 모델에게 저해상도 이미지를 보여주고, 그 후 입력 이미지 크기에 따라 512px 의 정사각형 세부 사진을 만듭니다. 각 세부 사진에 대해 129 토큰의 예산을 사용합니다.', high: '고', low: '저', uploadMethod: '업로드 방식', diff --git a/web/i18n/pl-PL/app-debug.ts b/web/i18n/pl-PL/app-debug.ts index 262b4a204f..943896effc 100644 --- a/web/i18n/pl-PL/app-debug.ts +++ b/web/i18n/pl-PL/app-debug.ts @@ -379,9 +379,7 @@ const translation = { visionSettings: { title: 'Ustawienia Wizji', resolution: 'Rozdzielczość', - resolutionTooltip: `niska rozdzielczość pozwoli modelowi odbierać obrazy o rozdzielczości 512 x 512 i reprezentować obraz z limitem 65 tokenów. Pozwala to API na szybsze odpowiedzi i zużywa mniej tokenów wejściowych dla przypadków, które nie wymagają wysokiego szczegółu. - \n - wysoka rozdzielczość pozwala najpierw modelowi zobaczyć obraz niskiej rozdzielczości, a następnie tworzy szczegółowe przycięcia obrazów wejściowych jako 512px kwadratów w oparciu o rozmiar obrazu wejściowego. Każde z tych szczegółowych przycięć używa dwukrotności budżetu tokenów, co daje razem 129 tokenów.`, + resolutionTooltip: 'niska rozdzielczość pozwoli modelowi odbierać obrazy o rozdzielczości 512 x 512 i reprezentować obraz z limitem 65 tokenów. Pozwala to API na szybsze odpowiedzi i zużywa mniej tokenów wejściowych dla przypadków, które nie wymagają wysokiego szczegółu.\nwysoka rozdzielczość pozwala najpierw modelowi zobaczyć obraz niskiej rozdzielczości, a następnie tworzy szczegółowe przycięcia obrazów wejściowych jako 512px kwadratów w oparciu o rozmiar obrazu wejściowego. Każde z tych szczegółowych przycięć używa dwukrotności budżetu tokenów, co daje razem 129 tokenów.', high: 'Wysoka', low: 'Niska', uploadMethod: 'Metoda przesyłania', diff --git a/web/i18n/pt-BR/app-debug.ts b/web/i18n/pt-BR/app-debug.ts index d578cb5a84..30b9f59dd4 100644 --- a/web/i18n/pt-BR/app-debug.ts +++ b/web/i18n/pt-BR/app-debug.ts @@ -359,9 +359,7 @@ const translation = { visionSettings: { title: 'Configurações de Visão', resolution: 'Resolução', - resolutionTooltip: `Baixa resolução permitirá que o modelo receba uma versão de baixa resolução de 512 x 512 da imagem e represente a imagem com um orçamento de 65 tokens. Isso permite que a API retorne respostas mais rápidas e consuma menos tokens de entrada para casos de uso que não exigem alta precisão. - \n - Alta resolução permitirá que o modelo veja a imagem de baixa resolução e crie recortes detalhados das imagens de entrada como quadrados de 512px com base no tamanho da imagem de entrada. Cada um dos recortes detalhados usa o dobro do orçamento de tokens, totalizando 129 tokens.`, + resolutionTooltip: 'Baixa resolução permitirá que o modelo receba uma versão de baixa resolução de 512 x 512 da imagem e represente a imagem com um orçamento de 65 tokens. Isso permite que a API retorne respostas mais rápidas e consuma menos tokens de entrada para casos de uso que não exigem alta precisão.\nAlta resolução permitirá que o modelo veja a imagem de baixa resolução e crie recortes detalhados das imagens de entrada como quadrados de 512px com base no tamanho da imagem de entrada. Cada um dos recortes detalhados usa o dobro do orçamento de tokens, totalizando 129 tokens.', high: 'Alta', low: 'Baixa', uploadMethod: 'Método de Upload', diff --git a/web/i18n/ro-RO/app-debug.ts b/web/i18n/ro-RO/app-debug.ts index de8fd7a44f..8e36078be5 100644 --- a/web/i18n/ro-RO/app-debug.ts +++ b/web/i18n/ro-RO/app-debug.ts @@ -359,9 +359,7 @@ const translation = { visionSettings: { title: 'Setări Viziune', resolution: 'Rezoluție', - resolutionTooltip: `rezoluția joasă va permite modelului să primească o versiune de 512 x 512 pixeli a imaginii și să o reprezinte cu un buget de 65 de tokenuri. Acest lucru permite API-ului să returneze răspunsuri mai rapide și să consume mai puține tokenuri de intrare pentru cazurile de utilizare care nu necesită detalii ridicate. - \n - rezoluția ridicată va permite în primul rând modelului să vadă imaginea la rezoluție scăzută și apoi va crea decupaje detaliate ale imaginilor de intrare ca pătrate de 512 pixeli, în funcție de dimensiunea imaginii de intrare. Fiecare decupaj detaliat utilizează un buget de token dublu, pentru un total de 129 de tokenuri.`, + resolutionTooltip: 'rezoluția joasă va permite modelului să primească o versiune de 512 x 512 pixeli a imaginii și să o reprezinte cu un buget de 65 de tokenuri. Acest lucru permite API-ului să returneze răspunsuri mai rapide și să consume mai puține tokenuri de intrare pentru cazurile de utilizare care nu necesită detalii ridicate.\nrezoluția ridicată va permite în primul rând modelului să vadă imaginea la rezoluție scăzută și apoi va crea decupaje detaliate ale imaginilor de intrare ca pătrate de 512 pixeli, în funcție de dimensiunea imaginii de intrare. Fiecare decupaj detaliat utilizează un buget de token dublu, pentru un total de 129 de tokenuri.', high: 'Ridicat', low: 'Scăzut', uploadMethod: 'Metodă de încărcare', diff --git a/web/i18n/ru-RU/app-debug.ts b/web/i18n/ru-RU/app-debug.ts index 1d86e0778a..ea3b969df4 100644 --- a/web/i18n/ru-RU/app-debug.ts +++ b/web/i18n/ru-RU/app-debug.ts @@ -425,9 +425,7 @@ const translation = { visionSettings: { title: 'Настройки зрения', resolution: 'Разрешение', - resolutionTooltip: `Низкое разрешение позволит модели получать версию изображения с низким разрешением 512 x 512 и представлять изображение с бюджетом 65 токенов. Это позволяет API возвращать ответы быстрее и потреблять меньше входных токенов для случаев использования, не требующих высокой детализации. - \n - Высокое разрешение сначала позволит модели увидеть изображение с низким разрешением, а затем создаст детальные фрагменты входных изображений в виде квадратов 512 пикселей на основе размера входного изображения. Каждый из детальных фрагментов использует вдвое больший бюджет токенов, в общей сложности 129 токенов.`, + resolutionTooltip: 'Низкое разрешение позволит модели получать версию изображения с низким разрешением 512 x 512 и представлять изображение с бюджетом 65 токенов. Это позволяет API возвращать ответы быстрее и потреблять меньше входных токенов для случаев использования, не требующих высокой детализации.\nВысокое разрешение сначала позволит модели увидеть изображение с низким разрешением, а затем создаст детальные фрагменты входных изображений в виде квадратов 512 пикселей на основе размера входного изображения. Каждый из детальных фрагментов использует вдвое больший бюджет токенов, в общей сложности 129 токенов.', high: 'Высокое', low: 'Низкое', uploadMethod: 'Метод загрузки', diff --git a/web/i18n/uk-UA/app-debug.ts b/web/i18n/uk-UA/app-debug.ts index c593d2a730..18b4d32163 100644 --- a/web/i18n/uk-UA/app-debug.ts +++ b/web/i18n/uk-UA/app-debug.ts @@ -373,9 +373,7 @@ const translation = { visionSettings: { title: 'Налаштування зображень', // Vision Settings resolution: 'Роздільна здатність', // Resolution - resolutionTooltip: `низька роздільна здатність дозволить моделі отримати зображення з низькою роздільною здатністю 512 x 512 пікселів і представити зображення з обмеженням у 65 токенів. Це дозволяє API швидше повертати відповіді та споживати менше вхідних токенів для випадків використання, які не потребують високої деталізації. - \n - висока роздільна здатність спочатку дозволить моделі побачити зображення з низькою роздільною здатністю, а потім створити детальні фрагменти вхідних зображень у вигляді квадратів 512px на основі розміру вхідного зображення. Кожен із детальних фрагментів використовує подвійний запас токенів, загалом 129 токенів.`, + resolutionTooltip: 'низька роздільна здатність дозволить моделі отримати зображення з низькою роздільною здатністю 512 x 512 пікселів і представити зображення з обмеженням у 65 токенів. Це дозволяє API швидше повертати відповіді та споживати менше вхідних токенів для випадків використання, які не потребують високої деталізації.\nвисока роздільна здатність спочатку дозволить моделі побачити зображення з низькою роздільною здатністю, а потім створити детальні фрагменти вхідних зображень у вигляді квадратів 512px на основі розміру вхідного зображення. Кожен із детальних фрагментів використовує подвійний запас токенів, загалом 129 токенів.', high: 'Висока', // High low: 'Низька', // Low uploadMethod: 'Спосіб завантаження', // Upload Method diff --git a/web/i18n/vi-VN/app-debug.ts b/web/i18n/vi-VN/app-debug.ts index 150dfe488b..158a6b6ce9 100644 --- a/web/i18n/vi-VN/app-debug.ts +++ b/web/i18n/vi-VN/app-debug.ts @@ -353,9 +353,7 @@ const translation = { visionSettings: { title: 'Cài đặt thị giác', resolution: 'Độ phân giải', - resolutionTooltip: `Độ phân giải thấp sẽ cho phép mô hình nhận một phiên bản hình ảnh 512 x 512 thấp hơn, và đại diện cho hình ảnh với ngân sách 65 token. Điều này cho phép API trả về phản hồi nhanh hơn và tiêu thụ ít token đầu vào cho các trường hợp sử dụng không yêu cầu chi tiết cao. - \n - Độ phân giải cao sẽ đầu tiên cho phép mô hình nhìn thấy hình ảnh thấp hơn và sau đó tạo ra các cắt chi tiết của hình ảnh đầu vào dưới dạng hình vuông 512px dựa trên kích thước hình ảnh đầu vào. Mỗi cắt chi tiết sử dụng hai lần ngân sách token cho tổng cộng 129 token.`, + resolutionTooltip: 'Độ phân giải thấp sẽ cho phép mô hình nhận một phiên bản hình ảnh 512 x 512 thấp hơn, và đại diện cho hình ảnh với ngân sách 65 token. Điều này cho phép API trả về phản hồi nhanh hơn và tiêu thụ ít token đầu vào cho các trường hợp sử dụng không yêu cầu chi tiết cao.\nĐộ phân giải cao sẽ đầu tiên cho phép mô hình nhìn thấy hình ảnh thấp hơn và sau đó tạo ra các cắt chi tiết của hình ảnh đầu vào dưới dạng hình vuông 512px dựa trên kích thước hình ảnh đầu vào. Mỗi cắt chi tiết sử dụng hai lần ngân sách token cho tổng cộng 129 token.', high: 'Cao', low: 'Thấp', uploadMethod: 'Phương thức tải lên', diff --git a/web/i18n/zh-Hans/app-debug.ts b/web/i18n/zh-Hans/app-debug.ts index e3df0ea9ea..5d6c2842a5 100644 --- a/web/i18n/zh-Hans/app-debug.ts +++ b/web/i18n/zh-Hans/app-debug.ts @@ -444,9 +444,7 @@ const translation = { visionSettings: { title: '视觉设置', resolution: '分辨率', - resolutionTooltip: `低分辨率模式将使模型接收图像的低分辨率版本,尺寸为 512 x 512,并使用 65 Tokens 来表示图像。这样可以使 API 更快地返回响应,并在不需要高细节的用例中消耗更少的输入。 - \n - 高分辨率模式将首先允许模型查看低分辨率图像,然后根据输入图像的大小创建 512 像素的详细裁剪图像。每个详细裁剪图像使用两倍的预算总共为 129 Tokens。`, + resolutionTooltip: '低分辨率模式将使模型接收图像的低分辨率版本,尺寸为 512 x 512,并使用 65 Tokens 来表示图像。这样可以使 API 更快地返回响应,并在不需要高细节的用例中消耗更少的输入。\n高分辨率模式将首先允许模型查看低分辨率图像,然后根据输入图像的大小创建 512 像素的详细裁剪图像。每个详细裁剪图像使用两倍的预算总共为 129 Tokens。', high: '高', low: '低', uploadMethod: '上传方式', diff --git a/web/i18n/zh-Hant/app-debug.ts b/web/i18n/zh-Hant/app-debug.ts index b66fcb9816..78b179097d 100644 --- a/web/i18n/zh-Hant/app-debug.ts +++ b/web/i18n/zh-Hant/app-debug.ts @@ -353,9 +353,7 @@ const translation = { visionSettings: { title: '視覺設定', resolution: '解析度', - resolutionTooltip: `低解析度模式將使模型接收影象的低解析度版本,尺寸為 512 x 512,並使用 65 Tokens 來表示影象。這樣可以使 API 更快地返回響應,並在不需要高細節的用例中消耗更少的輸入。 - \n - 高解析度模式將首先允許模型檢視低解析度影象,然後根據輸入影象的大小建立 512 畫素的詳細裁剪影象。每個詳細裁剪影象使用兩倍的預算總共為 129 Tokens。`, + resolutionTooltip: '低解析度模式將使模型接收影象的低解析度版本,尺寸為 512 x 512,並使用 65 Tokens 來表示影象。這樣可以使 API 更快地返回響應,並在不需要高細節的用例中消耗更少的輸入。\n高解析度模式將首先允許模型檢視低解析度影象,然後根據輸入影象的大小建立 512 畫素的詳細裁剪影象。每個詳細裁剪影象使用兩倍的預算總共為 129 Tokens。', high: '高', low: '低', uploadMethod: '上傳方式', From 2f9d718997393e38e369a8ab378ad12a8b89a743 Mon Sep 17 00:00:00 2001 From: wangxiaolei Date: Wed, 24 Dec 2025 17:23:30 +0800 Subject: [PATCH 07/11] fix: fix use build_request lead unexpect param (#30095) --- api/core/helper/ssrf_proxy.py | 10 ++- .../unit_tests/core/helper/test_ssrf_proxy.py | 61 ++----------------- 2 files changed, 8 insertions(+), 63 deletions(-) diff --git a/api/core/helper/ssrf_proxy.py b/api/core/helper/ssrf_proxy.py index f2172e4e2f..0b36969cf9 100644 --- a/api/core/helper/ssrf_proxy.py +++ b/api/core/helper/ssrf_proxy.py @@ -118,13 +118,11 @@ def make_request(method, url, max_retries=SSRF_DEFAULT_MAX_RETRIES, **kwargs): # Build the request manually to preserve the Host header # httpx may override the Host header when using a proxy, so we use # the request API to explicitly set headers before sending - request = client.build_request(method=method, url=url, **kwargs) - - # If user explicitly provided a Host header, ensure it's preserved + headers = {k: v for k, v in headers.items() if k.lower() != "host"} if user_provided_host is not None: - request.headers["Host"] = user_provided_host - - response = client.send(request) + headers["host"] = user_provided_host + kwargs["headers"] = headers + response = client.request(method=method, url=url, **kwargs) # Check for SSRF protection by Squid proxy if response.status_code in (401, 403): 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 d5bc3283fe..beae1d0358 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,9 @@ -import secrets from unittest.mock import MagicMock, patch import pytest from core.helper.ssrf_proxy import ( SSRF_DEFAULT_MAX_RETRIES, - STATUS_FORCELIST, _get_user_provided_host_header, make_request, ) @@ -14,11 +12,10 @@ from core.helper.ssrf_proxy import ( @patch("core.helper.ssrf_proxy._get_ssrf_client") def test_successful_request(mock_get_client): mock_client = MagicMock() - mock_request = MagicMock() mock_response = MagicMock() mock_response.status_code = 200 mock_client.send.return_value = mock_response - mock_client.build_request.return_value = mock_request + mock_client.request.return_value = mock_response mock_get_client.return_value = mock_client response = make_request("GET", "http://example.com") @@ -28,11 +25,10 @@ def test_successful_request(mock_get_client): @patch("core.helper.ssrf_proxy._get_ssrf_client") def test_retry_exceed_max_retries(mock_get_client): mock_client = MagicMock() - mock_request = MagicMock() mock_response = MagicMock() mock_response.status_code = 500 mock_client.send.return_value = mock_response - mock_client.build_request.return_value = mock_request + mock_client.request.return_value = mock_response mock_get_client.return_value = mock_client with pytest.raises(Exception) as e: @@ -40,32 +36,6 @@ def test_retry_exceed_max_retries(mock_get_client): assert str(e.value) == f"Reached maximum retries ({SSRF_DEFAULT_MAX_RETRIES - 1}) for URL http://example.com" -@patch("core.helper.ssrf_proxy._get_ssrf_client") -def test_retry_logic_success(mock_get_client): - mock_client = MagicMock() - mock_request = MagicMock() - mock_response = MagicMock() - mock_response.status_code = 200 - - side_effects = [] - for _ in range(SSRF_DEFAULT_MAX_RETRIES): - status_code = secrets.choice(STATUS_FORCELIST) - retry_response = MagicMock() - retry_response.status_code = status_code - side_effects.append(retry_response) - - side_effects.append(mock_response) - mock_client.send.side_effect = side_effects - mock_client.build_request.return_value = mock_request - mock_get_client.return_value = mock_client - - response = make_request("GET", "http://example.com", max_retries=SSRF_DEFAULT_MAX_RETRIES) - - assert response.status_code == 200 - assert mock_client.send.call_count == SSRF_DEFAULT_MAX_RETRIES + 1 - assert mock_client.build_request.call_count == SSRF_DEFAULT_MAX_RETRIES + 1 - - class TestGetUserProvidedHostHeader: """Tests for _get_user_provided_host_header function.""" @@ -111,14 +81,12 @@ def test_host_header_preservation_without_user_header(mock_get_client): mock_response = MagicMock() mock_response.status_code = 200 mock_client.send.return_value = mock_response - mock_client.build_request.return_value = mock_request + mock_client.request.return_value = mock_response mock_get_client.return_value = mock_client response = make_request("GET", "http://example.com") assert response.status_code == 200 - # build_request should be called without headers dict containing Host - mock_client.build_request.assert_called_once() # Host should not be set if not provided by user assert "Host" not in mock_request.headers or mock_request.headers.get("Host") is None @@ -132,31 +100,10 @@ def test_host_header_preservation_with_user_header(mock_get_client): mock_response = MagicMock() mock_response.status_code = 200 mock_client.send.return_value = mock_response - mock_client.build_request.return_value = mock_request + mock_client.request.return_value = mock_response mock_get_client.return_value = mock_client custom_host = "custom.example.com:8080" response = make_request("GET", "http://example.com", headers={"Host": custom_host}) assert response.status_code == 200 - # Verify build_request was called - mock_client.build_request.assert_called_once() - # Verify the Host header was set on the request object - assert mock_request.headers.get("Host") == custom_host - mock_client.send.assert_called_once_with(mock_request) - - -@patch("core.helper.ssrf_proxy._get_ssrf_client") -@pytest.mark.parametrize("host_key", ["host", "HOST"]) -def test_host_header_preservation_case_insensitive(mock_get_client, host_key): - """Test that Host header is preserved regardless of case.""" - mock_client = MagicMock() - mock_request = MagicMock() - mock_request.headers = {} - mock_response = MagicMock() - mock_response.status_code = 200 - mock_client.send.return_value = mock_response - mock_client.build_request.return_value = mock_request - mock_get_client.return_value = mock_client - response = make_request("GET", "http://example.com", headers={host_key: "api.example.com"}) - assert mock_request.headers.get("Host") == "api.example.com" From 64a14dcdbcc79498a22a36f592c711c0f28049f0 Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Wed, 24 Dec 2025 18:20:36 +0800 Subject: [PATCH 08/11] fix(web): remove incorrect placeholderData usage in useExploreAppList (#30102) --- .../app/create-app-dialog/app-list/index.tsx | 14 +++++++---- .../explore/app-list/index.spec.tsx | 18 ++++++++++---- web/app/components/explore/app-list/index.tsx | 24 ++++++++++++------- web/service/use-explore.ts | 6 ----- 4 files changed, 37 insertions(+), 25 deletions(-) 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 ee64478141..0df13e1ba1 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 @@ -24,7 +24,7 @@ import { useTabSearchParams } from '@/hooks/use-tab-searchparams' import { DSLImportMode } from '@/models/app' import { importDSL } from '@/service/apps' import { fetchAppDetail } from '@/service/explore' -import { exploreAppListInitialData, useExploreAppList } from '@/service/use-explore' +import { useExploreAppList } from '@/service/use-explore' import { AppModeEnum } from '@/types/app' import { getRedirection } from '@/utils/app-redirection' import { cn } from '@/utils/classnames' @@ -70,10 +70,14 @@ const Apps = ({ }) const { - data: { categories, allList } = exploreAppListInitialData, + data, + isLoading, } = useExploreAppList() const filteredList = useMemo(() => { + if (!data) + return [] + const { allList } = data const filteredByCategory = allList.filter((item) => { if (currCategory === allCategoriesEn) return true @@ -94,7 +98,7 @@ const Apps = ({ return true return false }) - }, [currentType, currCategory, allCategoriesEn, allList]) + }, [currentType, currCategory, allCategoriesEn, data]) const searchFilteredList = useMemo(() => { if (!searchKeywords || !filteredList || filteredList.length === 0) @@ -156,7 +160,7 @@ const Apps = ({ } } - if (!categories || categories.length === 0) { + if (isLoading) { return (
@@ -190,7 +194,7 @@ const Apps = ({
{!searchKeywords && (
- { setCurrCategory(category) }} onCreateFromBlank={onCreateFromBlank} /> + { setCurrCategory(category) }} onCreateFromBlank={onCreateFromBlank} />
)}
diff --git a/web/app/components/explore/app-list/index.spec.tsx b/web/app/components/explore/app-list/index.spec.tsx index ebf2c9c075..c84da1931c 100644 --- a/web/app/components/explore/app-list/index.spec.tsx +++ b/web/app/components/explore/app-list/index.spec.tsx @@ -10,7 +10,9 @@ import AppList from './index' const allCategoriesEn = 'explore.apps.allCategories:{"lng":"en"}' let mockTabValue = allCategoriesEn const mockSetTab = vi.fn() -let mockExploreData: { categories: string[], allList: App[] } = { categories: [], allList: [] } +let mockExploreData: { categories: string[], allList: App[] } | undefined = { categories: [], allList: [] } +let mockIsLoading = false +let mockIsError = false const mockHandleImportDSL = vi.fn() const mockHandleImportDSLConfirm = vi.fn() @@ -34,8 +36,11 @@ vi.mock('ahooks', async () => { }) vi.mock('@/service/use-explore', () => ({ - exploreAppListInitialData: { categories: [], allList: [] }, - useExploreAppList: () => ({ data: mockExploreData }), + useExploreAppList: () => ({ + data: mockExploreData, + isLoading: mockIsLoading, + isError: mockIsError, + }), })) vi.mock('@/service/explore', () => ({ @@ -136,13 +141,16 @@ describe('AppList', () => { vi.clearAllMocks() mockTabValue = allCategoriesEn mockExploreData = { categories: [], allList: [] } + mockIsLoading = false + mockIsError = false }) // Rendering: show loading when categories are not ready. describe('Rendering', () => { - it('should render loading when categories are empty', () => { + it('should render loading when the query is loading', () => { // Arrange - mockExploreData = { categories: [], allList: [] } + mockExploreData = undefined + mockIsLoading = true // Act renderWithContext() diff --git a/web/app/components/explore/app-list/index.tsx b/web/app/components/explore/app-list/index.tsx index 244a116e36..5ab68f9b04 100644 --- a/web/app/components/explore/app-list/index.tsx +++ b/web/app/components/explore/app-list/index.tsx @@ -20,7 +20,7 @@ import { DSLImportMode, } from '@/models/app' import { fetchAppDetail } from '@/service/explore' -import { exploreAppListInitialData, useExploreAppList } from '@/service/use-explore' +import { useExploreAppList } from '@/service/use-explore' import { cn } from '@/utils/classnames' import s from './style.module.css' @@ -28,11 +28,6 @@ type AppsProps = { onSuccess?: () => void } -export enum PageType { - EXPLORE = 'explore', - CREATE = 'create', -} - const Apps = ({ onSuccess, }: AppsProps) => { @@ -58,10 +53,16 @@ const Apps = ({ }) const { - data: { categories, allList } = exploreAppListInitialData, + data, + isLoading, + isError, } = useExploreAppList() - const filteredList = allList.filter(item => currCategory === allCategoriesEn || item.category === currCategory) + const filteredList = useMemo(() => { + if (!data) + return [] + return data.allList.filter(item => currCategory === allCategoriesEn || item.category === currCategory) + }, [data, currCategory, allCategoriesEn]) const searchFilteredList = useMemo(() => { if (!searchKeywords || !filteredList || filteredList.length === 0) @@ -119,7 +120,7 @@ const Apps = ({ }) }, [handleImportDSLConfirm, onSuccess]) - if (!categories || categories.length === 0) { + if (isLoading) { return (
@@ -127,6 +128,11 @@ const Apps = ({ ) } + if (isError || !data) + return null + + const { categories } = data + return (
{ return useQuery({ queryKey: [NAME_SPACE, 'appList'], @@ -27,7 +22,6 @@ export const useExploreAppList = () => { allList: [...recommended_apps].sort((a, b) => a.position - b.position), } }, - placeholderData: exploreAppListInitialData, }) } From 5896bc89f5e2d1f939fc5a3b35c63b43341a6121 Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Wed, 24 Dec 2025 18:21:01 +0800 Subject: [PATCH 09/11] refactor(web): migrate workflow run history from useSWR to TanStack Query (#30077) --- .../components/rag-pipeline-header/index.tsx | 2 -- .../components/workflow-header/index.spec.tsx | 11 -------- .../components/workflow-header/index.tsx | 4 --- .../workflow/header/view-history.tsx | 11 +++----- web/service/use-workflow.ts | 9 +++++++ web/service/workflow.ts | 27 +++++-------------- 6 files changed, 19 insertions(+), 45 deletions(-) diff --git a/web/app/components/rag-pipeline/components/rag-pipeline-header/index.tsx b/web/app/components/rag-pipeline/components/rag-pipeline-header/index.tsx index fff720469c..fe2490f281 100644 --- a/web/app/components/rag-pipeline/components/rag-pipeline-header/index.tsx +++ b/web/app/components/rag-pipeline/components/rag-pipeline-header/index.tsx @@ -8,7 +8,6 @@ import Header from '@/app/components/workflow/header' import { useStore, } from '@/app/components/workflow/store' -import { fetchWorkflowRunHistory } from '@/service/workflow' import InputFieldButton from './input-field-button' import Publisher from './publisher' import RunMode from './run-mode' @@ -21,7 +20,6 @@ const RagPipelineHeader = () => { const viewHistoryProps = useMemo(() => { return { historyUrl: `/rag/pipelines/${pipelineId}/workflow-runs`, - historyFetcher: fetchWorkflowRunHistory, } }, [pipelineId]) diff --git a/web/app/components/workflow-app/components/workflow-header/index.spec.tsx b/web/app/components/workflow-app/components/workflow-header/index.spec.tsx index 87d7fb30e7..5563af01d3 100644 --- a/web/app/components/workflow-app/components/workflow-header/index.spec.tsx +++ b/web/app/components/workflow-app/components/workflow-header/index.spec.tsx @@ -58,16 +58,12 @@ vi.mock('@/app/components/app/store', () => ({ vi.mock('@/app/components/workflow/header', () => ({ __esModule: true, default: (props: HeaderProps) => { - const historyFetcher = props.normal?.runAndHistoryProps?.viewHistoryProps?.historyFetcher - const hasHistoryFetcher = typeof historyFetcher === 'function' - return (
- - - +
+ + + + + + +
@@ -78,6 +94,14 @@ const SubscriptionCard = ({ data }: Props) => { workflowsInUse={data.workflows_in_use} /> )} + + {isShowEditModal && ( + + )} ) } diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/types.ts b/web/app/components/plugins/plugin-detail-panel/subscription-list/types.ts new file mode 100644 index 0000000000..adfda16547 --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/types.ts @@ -0,0 +1,9 @@ +export enum SubscriptionListMode { + PANEL = 'panel', + SELECTOR = 'selector', +} + +export type SimpleSubscription = { + id: string + name: string +} diff --git a/web/app/components/plugins/types.ts b/web/app/components/plugins/types.ts index 818c2a0388..4aa0326cb4 100644 --- a/web/app/components/plugins/types.ts +++ b/web/app/components/plugins/types.ts @@ -131,7 +131,7 @@ export type ParametersSchema = { scope: any required: boolean multiple: boolean - default?: string[] + default?: string | string[] min: any max: any precision: any diff --git a/web/app/components/workflow/block-selector/types.ts b/web/app/components/workflow/block-selector/types.ts index 6ed4d7f2d5..07efb0d02f 100644 --- a/web/app/components/workflow/block-selector/types.ts +++ b/web/app/components/workflow/block-selector/types.ts @@ -39,9 +39,9 @@ export type TriggerDefaultValue = PluginCommonDefaultValue & { title: string plugin_unique_identifier: string is_team_authorization: boolean - params: Record - paramSchemas: Record[] - output_schema: Record + params: Record + paramSchemas: Record[] + output_schema: Record subscription_id?: string meta?: PluginMeta } @@ -52,9 +52,9 @@ export type ToolDefaultValue = PluginCommonDefaultValue & { tool_description: string title: string is_team_authorization: boolean - params: Record - paramSchemas: Record[] - output_schema?: Record + params: Record + paramSchemas: Record[] + output_schema?: Record credential_id?: string meta?: PluginMeta plugin_id?: string @@ -82,10 +82,10 @@ export type ToolValue = { tool_name: string tool_label: string tool_description?: string - settings?: Record - parameters?: Record + settings?: Record + parameters?: Record enabled?: boolean - extra?: Record + extra?: { description?: string } & Record credential_id?: string } @@ -94,7 +94,7 @@ export type DataSourceItem = { plugin_unique_identifier: string provider: string declaration: { - credentials_schema: any[] + credentials_schema: unknown[] provider_type: string identity: { author: string @@ -113,10 +113,10 @@ export type DataSourceItem = { name: string provider: string } - parameters: any[] + parameters: unknown[] output_schema?: { type: string - properties: Record + properties: Record } }[] } @@ -133,15 +133,15 @@ export type TriggerParameter = { | 'model-selector' | 'app-selector' | 'object' | 'array' | 'dynamic-select' auto_generate?: { type: string - value?: any + value?: unknown } | null template?: { type: string - value?: any + value?: unknown } | null scope?: string | null required?: boolean - default?: any + default?: unknown min?: number | null max?: number | null precision?: number | null @@ -158,7 +158,7 @@ export type TriggerCredentialField = { name: string scope?: string | null required: boolean - default?: string | number | boolean | Array | null + default?: string | number | boolean | Array | null options?: Array<{ value: string label: TypeWithI18N @@ -191,7 +191,7 @@ export type TriggerApiEntity = { identity: TriggerIdentity description: TypeWithI18N parameters: TriggerParameter[] - output_schema?: Record + output_schema?: Record } export type TriggerProviderApiEntity = { @@ -237,32 +237,15 @@ type TriggerSubscriptionStructure = { name: string provider: string credential_type: TriggerCredentialTypeEnum - credentials: TriggerSubCredentials + credentials: Record endpoint: string - parameters: TriggerSubParameters - properties: TriggerSubProperties + parameters: Record + properties: Record workflows_in_use: number } export type TriggerSubscription = TriggerSubscriptionStructure -export type TriggerSubCredentials = { - access_tokens: string -} - -export type TriggerSubParameters = { - repository: string - webhook_secret?: string -} - -export type TriggerSubProperties = { - active: boolean - events: string[] - external_id: string - repository: string - webhook_secret?: string -} - export type TriggerSubscriptionBuilder = TriggerSubscriptionStructure // OAuth configuration types @@ -275,7 +258,7 @@ export type TriggerOAuthConfig = { params: { client_id: string client_secret: string - [key: string]: any + [key: string]: string } system_configured: boolean } diff --git a/web/app/components/workflow/nodes/trigger-plugin/hooks/use-trigger-auth-flow.ts b/web/app/components/workflow/nodes/trigger-plugin/hooks/use-trigger-auth-flow.ts index 36bcbf1cc7..f551f2f420 100644 --- a/web/app/components/workflow/nodes/trigger-plugin/hooks/use-trigger-auth-flow.ts +++ b/web/app/components/workflow/nodes/trigger-plugin/hooks/use-trigger-auth-flow.ts @@ -4,11 +4,11 @@ import { useBuildTriggerSubscription, useCreateTriggerSubscriptionBuilder, useUpdateTriggerSubscriptionBuilder, - useVerifyTriggerSubscriptionBuilder, + useVerifyAndUpdateTriggerSubscriptionBuilder, } from '@/service/use-triggers' // Helper function to serialize complex values to strings for backend encryption -const serializeFormValues = (values: Record): Record => { +const serializeFormValues = (values: Record): Record => { const result: Record = {} for (const [key, value] of Object.entries(values)) { @@ -23,6 +23,17 @@ const serializeFormValues = (values: Record): Record { + if (error instanceof Error && error.message) + return error.message + if (typeof error === 'object' && error && 'message' in error) { + const message = (error as { message?: string }).message + if (typeof message === 'string' && message) + return message + } + return fallback +} + export type AuthFlowStep = 'auth' | 'params' | 'complete' export type AuthFlowState = { @@ -34,8 +45,8 @@ export type AuthFlowState = { export type AuthFlowActions = { startAuth: () => Promise - verifyAuth: (credentials: Record) => Promise - completeConfig: (parameters: Record, properties?: Record, name?: string) => Promise + verifyAuth: (credentials: Record) => Promise + completeConfig: (parameters: Record, properties?: Record, name?: string) => Promise reset: () => void } @@ -47,7 +58,7 @@ export const useTriggerAuthFlow = (provider: TriggerWithProvider): AuthFlowState const createBuilder = useCreateTriggerSubscriptionBuilder() const updateBuilder = useUpdateTriggerSubscriptionBuilder() - const verifyBuilder = useVerifyTriggerSubscriptionBuilder() + const verifyBuilder = useVerifyAndUpdateTriggerSubscriptionBuilder() const buildSubscription = useBuildTriggerSubscription() const startAuth = useCallback(async () => { @@ -64,8 +75,8 @@ export const useTriggerAuthFlow = (provider: TriggerWithProvider): AuthFlowState setBuilderId(response.subscription_builder.id) setStep('auth') } - catch (err: any) { - setError(err.message || 'Failed to start authentication flow') + catch (err: unknown) { + setError(getErrorMessage(err, 'Failed to start authentication flow')) throw err } finally { @@ -73,7 +84,7 @@ export const useTriggerAuthFlow = (provider: TriggerWithProvider): AuthFlowState } }, [provider.name, createBuilder, builderId]) - const verifyAuth = useCallback(async (credentials: Record) => { + const verifyAuth = useCallback(async (credentials: Record) => { if (!builderId) { setError('No builder ID available') return @@ -96,8 +107,8 @@ export const useTriggerAuthFlow = (provider: TriggerWithProvider): AuthFlowState setStep('params') } - catch (err: any) { - setError(err.message || 'Authentication verification failed') + catch (err: unknown) { + setError(getErrorMessage(err, 'Authentication verification failed')) throw err } finally { @@ -106,8 +117,8 @@ export const useTriggerAuthFlow = (provider: TriggerWithProvider): AuthFlowState }, [provider.name, builderId, updateBuilder, verifyBuilder]) const completeConfig = useCallback(async ( - parameters: Record, - properties: Record = {}, + parameters: Record, + properties: Record = {}, name?: string, ) => { if (!builderId) { @@ -134,8 +145,8 @@ export const useTriggerAuthFlow = (provider: TriggerWithProvider): AuthFlowState setStep('complete') } - catch (err: any) { - setError(err.message || 'Configuration failed') + catch (err: unknown) { + setError(getErrorMessage(err, 'Configuration failed')) throw err } finally { diff --git a/web/i18n/en-US/common.ts b/web/i18n/en-US/common.ts index 2e378afeda..0117f2ae00 100644 --- a/web/i18n/en-US/common.ts +++ b/web/i18n/en-US/common.ts @@ -21,6 +21,7 @@ const translation = { cancel: 'Cancel', clear: 'Clear', save: 'Save', + saving: 'Saving...', yes: 'Yes', no: 'No', deleteConfirmTitle: 'Delete?', diff --git a/web/i18n/en-US/plugin-trigger.ts b/web/i18n/en-US/plugin-trigger.ts index f1d697e507..16ee1e1362 100644 --- a/web/i18n/en-US/plugin-trigger.ts +++ b/web/i18n/en-US/plugin-trigger.ts @@ -30,6 +30,11 @@ const translation = { unauthorized: 'Manual', }, actions: { + edit: { + title: 'Edit Subscription', + success: 'Subscription updated successfully', + error: 'Failed to update subscription', + }, delete: 'Delete', deleteConfirm: { title: 'Delete {{name}}?', diff --git a/web/service/use-triggers.ts b/web/service/use-triggers.ts index c21d1aa979..6036f5ab34 100644 --- a/web/service/use-triggers.ts +++ b/web/service/use-triggers.ts @@ -1,3 +1,4 @@ +import type { FormOption } from '@/app/components/base/form/types' import type { TriggerLogEntity, TriggerOAuthClientParams, @@ -149,9 +150,9 @@ export const useUpdateTriggerSubscriptionBuilder = () => { provider: string subscriptionBuilderId: string name?: string - properties?: Record - parameters?: Record - credentials?: Record + properties?: Record + parameters?: Record + credentials?: Record }) => { const { provider, subscriptionBuilderId, ...body } = payload return post( @@ -162,17 +163,35 @@ export const useUpdateTriggerSubscriptionBuilder = () => { }) } -export const useVerifyTriggerSubscriptionBuilder = () => { +export const useVerifyAndUpdateTriggerSubscriptionBuilder = () => { return useMutation({ - mutationKey: [NAME_SPACE, 'verify-subscription-builder'], + mutationKey: [NAME_SPACE, 'verify-and-update-subscription-builder'], mutationFn: (payload: { provider: string subscriptionBuilderId: string - credentials?: Record + credentials?: Record }) => { const { provider, subscriptionBuilderId, ...body } = payload return post<{ verified: boolean }>( - `/workspaces/current/trigger-provider/${provider}/subscriptions/builder/verify/${subscriptionBuilderId}`, + `/workspaces/current/trigger-provider/${provider}/subscriptions/builder/verify-and-update/${subscriptionBuilderId}`, + { body }, + { silent: true }, + ) + }, + }) +} + +export const useVerifyTriggerSubscription = () => { + return useMutation({ + mutationKey: [NAME_SPACE, 'verify-subscription'], + mutationFn: (payload: { + provider: string + subscriptionId: string + credentials?: Record + }) => { + const { provider, subscriptionId, ...body } = payload + return post<{ verified: boolean }>( + `/workspaces/current/trigger-provider/${provider}/subscriptions/verify/${subscriptionId}`, { body }, { silent: true }, ) @@ -184,7 +203,7 @@ export type BuildTriggerSubscriptionPayload = { provider: string subscriptionBuilderId: string name?: string - parameters?: Record + parameters?: Record } export const useBuildTriggerSubscription = () => { @@ -211,6 +230,27 @@ export const useDeleteTriggerSubscription = () => { }) } +export type UpdateTriggerSubscriptionPayload = { + subscriptionId: string + name?: string + properties?: Record + parameters?: Record + credentials?: Record +} + +export const useUpdateTriggerSubscription = () => { + return useMutation({ + mutationKey: [NAME_SPACE, 'update-subscription'], + mutationFn: (payload: UpdateTriggerSubscriptionPayload) => { + const { subscriptionId, ...body } = payload + return post<{ result: string, id: string }>( + `/workspaces/current/trigger-provider/${subscriptionId}/subscriptions/update`, + { body }, + ) + }, + }) +} + export const useTriggerSubscriptionBuilderLogs = ( provider: string, subscriptionBuilderId: string, @@ -290,20 +330,45 @@ export const useTriggerPluginDynamicOptions = (payload: { action: string parameter: string credential_id: string - extra?: Record + credentials?: Record + extra?: Record }, enabled = true) => { - return useQuery<{ options: Array<{ value: string, label: any }> }>({ - queryKey: [NAME_SPACE, 'dynamic-options', payload.plugin_id, payload.provider, payload.action, payload.parameter, payload.credential_id, payload.extra], - queryFn: () => get<{ options: Array<{ value: string, label: any }> }>( - '/workspaces/current/plugin/parameters/dynamic-options', - { - params: { - ...payload, - provider_type: 'trigger', // Add required provider_type parameter + return useQuery<{ options: FormOption[] }>({ + queryKey: [NAME_SPACE, 'dynamic-options', payload.plugin_id, payload.provider, payload.action, payload.parameter, payload.credential_id, payload.credentials, payload.extra], + queryFn: () => { + // Use new endpoint with POST when credentials provided (for edit mode) + if (payload.credentials) { + return post<{ options: FormOption[] }>( + '/workspaces/current/plugin/parameters/dynamic-options-with-credentials', + { + body: { + plugin_id: payload.plugin_id, + provider: payload.provider, + action: payload.action, + parameter: payload.parameter, + credential_id: payload.credential_id, + credentials: payload.credentials, + }, + }, + { silent: true }, + ) + } + // Use original GET endpoint for normal cases + return get<{ options: FormOption[] }>( + '/workspaces/current/plugin/parameters/dynamic-options', + { + params: { + plugin_id: payload.plugin_id, + provider: payload.provider, + action: payload.action, + parameter: payload.parameter, + credential_id: payload.credential_id, + provider_type: 'trigger', + }, }, - }, - { silent: true }, - ), + { silent: true }, + ) + }, enabled: enabled && !!payload.plugin_id && !!payload.provider && !!payload.action && !!payload.parameter && !!payload.credential_id, retry: 0, }) From fdaeec7f7d4bbe76fea73be7bb71bb1d6bddf8de Mon Sep 17 00:00:00 2001 From: Maries Date: Wed, 24 Dec 2025 20:23:52 +0800 Subject: [PATCH 11/11] fix: trigger subscription delete not working for non-auto-created credentials (#30122) Co-authored-by: Claude Opus 4.5 --- .../trigger/trigger_provider_service.py | 29 +++++++++---------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/api/services/trigger/trigger_provider_service.py b/api/services/trigger/trigger_provider_service.py index 71f35dada6..57de9b3cee 100644 --- a/api/services/trigger/trigger_provider_service.py +++ b/api/services/trigger/trigger_provider_service.py @@ -359,10 +359,6 @@ class TriggerProviderService: raise ValueError(f"Trigger provider subscription {subscription_id} not found") credential_type: CredentialType = CredentialType.of(subscription.credential_type) - is_auto_created: bool = credential_type in [CredentialType.OAUTH2, CredentialType.API_KEY] - if not is_auto_created: - return None - provider_id = TriggerProviderID(subscription.provider_id) provider_controller: PluginTriggerProviderController = TriggerManager.get_trigger_provider( tenant_id=tenant_id, provider_id=provider_id @@ -372,17 +368,20 @@ class TriggerProviderService: controller=provider_controller, subscription=subscription, ) - try: - TriggerManager.unsubscribe_trigger( - tenant_id=tenant_id, - user_id=subscription.user_id, - provider_id=provider_id, - subscription=subscription.to_entity(), - credentials=encrypter.decrypt(subscription.credentials), - credential_type=credential_type, - ) - except Exception as e: - logger.exception("Error unsubscribing trigger", exc_info=e) + + is_auto_created: bool = credential_type in [CredentialType.OAUTH2, CredentialType.API_KEY] + if is_auto_created: + try: + TriggerManager.unsubscribe_trigger( + tenant_id=tenant_id, + user_id=subscription.user_id, + provider_id=provider_id, + subscription=subscription.to_entity(), + credentials=encrypter.decrypt(subscription.credentials), + credential_type=credential_type, + ) + except Exception as e: + logger.exception("Error unsubscribing trigger", exc_info=e) session.delete(subscription) # Clear cache