diff --git a/web/app/components/app/app-publisher/__tests__/index.spec.tsx b/web/app/components/app/app-publisher/__tests__/index.spec.tsx
new file mode 100644
index 0000000000..283cbb4b2b
--- /dev/null
+++ b/web/app/components/app/app-publisher/__tests__/index.spec.tsx
@@ -0,0 +1,208 @@
+import type { AppPublisherMenuContentProps } from '../menu-content.types'
+import type { AppDetailResponse } from '@/models/app'
+import { fireEvent, render, screen } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { defaultSystemFeatures } from '@/types/feature'
+import AppPublisher from '../index'
+
+const mockMenuContent = vi.fn()
+const mockUseAppPublisher = vi.fn()
+
+vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
+ PortalToFollowElem: ({
+ children,
+ open,
+ }: {
+ children: React.ReactNode
+ open: boolean
+ }) =>
{children}
,
+ PortalToFollowElemTrigger: ({
+ children,
+ onClick,
+ }: {
+ children: React.ReactNode
+ onClick?: () => void
+ }) => {children}
,
+ PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) => {children}
,
+}))
+
+vi.mock('@/app/components/app/overview/embedded', () => ({
+ default: ({
+ isShow,
+ onClose,
+ appBaseUrl,
+ accessToken,
+ }: {
+ isShow: boolean
+ onClose: () => void
+ appBaseUrl?: string
+ accessToken?: string
+ }) => (
+
+
+
+ ),
+}))
+
+vi.mock('../menu-content', () => ({
+ default: (props: AppPublisherMenuContentProps) => {
+ mockMenuContent(props)
+ return (
+
+
+
+ )
+ },
+}))
+
+vi.mock('../use-app-publisher', () => ({
+ useAppPublisher: (...args: unknown[]) => mockUseAppPublisher(...args),
+}))
+
+vi.mock('../../app-access-control', () => ({
+ default: ({
+ onClose,
+ onConfirm,
+ }: {
+ onClose: () => void
+ onConfirm: () => void
+ }) => (
+
+
+
+
+ ),
+}))
+
+const createHookState = () => ({
+ accessToken: 'app-token',
+ appBaseURL: 'https://apps.example.com',
+ appDetail: {
+ id: 'app-1',
+ site: {
+ access_token: 'app-token',
+ app_base_url: 'https://apps.example.com',
+ },
+ } as AppDetailResponse | undefined,
+ appURL: '/apps/app-1',
+ closeAppAccessControl: vi.fn(),
+ closeEmbeddingModal: vi.fn(),
+ crossAxisOffset: 8,
+ debugWithMultipleModel: false,
+ disabled: false,
+ disabledFunctionButton: false,
+ disabledFunctionTooltip: undefined,
+ draftUpdatedAt: 5678,
+ embeddingModalOpen: false,
+ formatTimeFromNow: (time: number) => `from-now:${time}`,
+ handleAccessControlUpdate: vi.fn(),
+ handleOpenEmbedding: vi.fn(),
+ handleOpenInExplore: vi.fn(),
+ handlePublish: vi.fn(),
+ handlePublishToMarketplace: vi.fn(),
+ handleRestore: vi.fn(),
+ handleTrigger: vi.fn(),
+ hasHumanInputNode: false,
+ hasTriggerNode: false,
+ inputs: [],
+ isAppAccessSet: true,
+ isChatApp: false,
+ isGettingAppWhiteListSubjects: false,
+ isGettingUserCanAccessApp: false,
+ missingStartNode: false,
+ multipleModelConfigs: [],
+ onRefreshData: vi.fn(),
+ open: false,
+ outputs: [],
+ publishDisabled: false,
+ publishLoading: false,
+ published: false,
+ publishedAt: 1234,
+ publishingToMarketplace: false,
+ setOpen: vi.fn(),
+ showAppAccessControl: false,
+ showAppAccessControlModal: vi.fn(),
+ startNodeLimitExceeded: false,
+ systemFeatures: defaultSystemFeatures,
+ toolPublished: false,
+ upgradeHighlightStyle: { color: 'red' },
+ workflowToolAvailable: true,
+ workflowToolDisabled: false,
+ workflowToolMessage: undefined,
+})
+
+describe('AppPublisher', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockUseAppPublisher.mockReturnValue(createHookState())
+ })
+
+ it('should render the publish trigger and forward state into the menu content', () => {
+ render()
+
+ const menuContentProps = mockMenuContent.mock.calls[0][0] as AppPublisherMenuContentProps
+
+ expect(screen.getByRole('button', { name: 'workflow.common.publish' })).toBeInTheDocument()
+ expect(screen.getByTestId('portal')).toHaveAttribute('data-open', 'false')
+ expect(menuContentProps).toEqual(expect.objectContaining({
+ appURL: '/apps/app-1',
+ publishedAt: 1234,
+ workflowToolDisabled: false,
+ }))
+ expect(screen.getByTestId('embedded-modal')).toHaveAttribute('data-app-base-url', 'https://apps.example.com')
+ expect(screen.getByTestId('embedded-modal')).toHaveAttribute('data-access-token', 'app-token')
+ })
+
+ it('should invoke the trigger handler when the publish button is clicked', () => {
+ const state = createHookState()
+ mockUseAppPublisher.mockReturnValue(state)
+
+ render()
+
+ fireEvent.click(screen.getByRole('button', { name: 'workflow.common.publish' }))
+
+ expect(state.handleTrigger).toHaveBeenCalledTimes(1)
+ })
+
+ it('should pass loading-disabled state to the publish button', () => {
+ const state = createHookState()
+ state.disabled = true
+ state.publishLoading = true
+ mockUseAppPublisher.mockReturnValue(state)
+
+ render()
+
+ expect(screen.getByRole('button', { name: /workflow\.common\.publish/i })).toBeDisabled()
+ })
+
+ it('should render access control when requested and wire the overlay callbacks', () => {
+ const state = createHookState()
+ state.embeddingModalOpen = true
+ state.showAppAccessControl = true
+ mockUseAppPublisher.mockReturnValue(state)
+
+ render()
+
+ expect(screen.getByTestId('embedded-modal')).toHaveAttribute('data-open', 'true')
+ expect(screen.getByTestId('access-control')).toBeInTheDocument()
+
+ fireEvent.click(screen.getByText('close-embedded'))
+ fireEvent.click(screen.getByText('confirm-access'))
+ fireEvent.click(screen.getByText('close-access'))
+
+ expect(state.closeEmbeddingModal).toHaveBeenCalledTimes(1)
+ expect(state.handleAccessControlUpdate).toHaveBeenCalledTimes(1)
+ expect(state.closeAppAccessControl).toHaveBeenCalledTimes(1)
+ })
+
+ it('should skip rendering access control when the app detail is absent', () => {
+ const state = createHookState()
+ state.appDetail = undefined
+ state.showAppAccessControl = true
+ mockUseAppPublisher.mockReturnValue(state)
+
+ render()
+
+ expect(screen.queryByTestId('access-control')).not.toBeInTheDocument()
+ })
+})
diff --git a/web/app/components/app/app-publisher/__tests__/menu-content-actions-section.spec.tsx b/web/app/components/app/app-publisher/__tests__/menu-content-actions-section.spec.tsx
new file mode 100644
index 0000000000..d1dfaee05c
--- /dev/null
+++ b/web/app/components/app/app-publisher/__tests__/menu-content-actions-section.spec.tsx
@@ -0,0 +1,110 @@
+import type { AppPublisherMenuContentProps } from '../menu-content.types'
+import type { AppDetailResponse } from '@/models/app'
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { describe, expect, it, vi } from 'vitest'
+import { toast } from '@/app/components/base/ui/toast'
+import { AppModeEnum } from '@/types/app'
+import MenuContentActionsSection from '../menu-content-actions-section'
+
+const mockWorkflowToolConfigureButton = vi.fn()
+
+vi.mock('@/app/components/base/ui/toast', () => ({
+ toast: {
+ error: vi.fn(),
+ },
+}))
+
+vi.mock('@/app/components/tools/workflow-tool/configure-button', () => ({
+ default: (props: {
+ detailNeedUpdate: boolean
+ disabled?: boolean
+ disabledReason?: string
+ handlePublish: (params?: unknown) => void
+ }) => {
+ mockWorkflowToolConfigureButton(props)
+ return (
+
+ )
+ },
+}))
+
+const createAppDetail = (overrides: Partial = {}): AppDetailResponse => ({
+ description: 'Workflow description',
+ icon: '๐ค',
+ icon_background: '#ffffff',
+ icon_type: 'emoji',
+ id: 'app-1',
+ mode: AppModeEnum.WORKFLOW,
+ name: 'Workflow app',
+ ...overrides,
+} as AppDetailResponse)
+
+const createProps = (overrides: Partial = {}): React.ComponentProps => ({
+ appDetail: createAppDetail(),
+ appURL: '/apps/app-1',
+ disabledFunctionButton: false,
+ disabledFunctionTooltip: undefined,
+ hasHumanInputNode: false,
+ hasTriggerNode: false,
+ inputs: [],
+ missingStartNode: false,
+ onOpenEmbedding: vi.fn(),
+ onOpenInExplore: vi.fn(),
+ onPublish: vi.fn(),
+ onRefreshData: vi.fn(),
+ outputs: [],
+ published: false,
+ publishedAt: 1234,
+ toolPublished: false,
+ workflowToolDisabled: false,
+ workflowToolMessage: undefined,
+ ...overrides,
+})
+
+describe('MenuContentActionsSection', () => {
+ it('should show a toast when explore is requested before publish and render the embed action for chat apps', async () => {
+ const user = userEvent.setup()
+ const onOpenEmbedding = vi.fn()
+
+ render(
+ ,
+ )
+
+ await user.click(screen.getByText('workflow.common.openInExplore'))
+ await user.click(screen.getByText('workflow.common.embedIntoSite'))
+
+ expect(toast.error).toHaveBeenCalledWith(expect.stringContaining('notPublishedYet'))
+ expect(onOpenEmbedding).not.toHaveBeenCalled()
+ })
+
+ it('should forward workflow tool publish requests to onPublish', async () => {
+ const user = userEvent.setup()
+ const onPublish = vi.fn().mockResolvedValue(undefined)
+
+ render(
+ ,
+ )
+
+ await user.click(screen.getByText('publish-workflow-tool'))
+
+ expect(mockWorkflowToolConfigureButton.mock.calls[0][0]).toEqual(expect.objectContaining({
+ detailNeedUpdate: true,
+ disabled: false,
+ disabledReason: undefined,
+ }))
+ expect(onPublish).toHaveBeenCalledWith({ tool: true })
+ })
+})
diff --git a/web/app/components/app/app-publisher/__tests__/menu-content-publish-section.spec.tsx b/web/app/components/app/app-publisher/__tests__/menu-content-publish-section.spec.tsx
new file mode 100644
index 0000000000..4a328c0d39
--- /dev/null
+++ b/web/app/components/app/app-publisher/__tests__/menu-content-publish-section.spec.tsx
@@ -0,0 +1,70 @@
+import type { ModelAndParameter } from '../../configuration/debug/types'
+import type { AppPublisherMenuContentProps } from '../menu-content.types'
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { describe, expect, it, vi } from 'vitest'
+import MenuContentPublishSection from '../menu-content-publish-section'
+
+vi.mock('@/app/components/billing/upgrade-btn', () => ({
+ default: () => upgrade-btn
,
+}))
+
+vi.mock('../../workflow/shortcuts-name', () => ({
+ default: () => shortcuts-name
,
+}))
+
+vi.mock('../publish-with-multiple-model', () => ({
+ default: ({
+ multipleModelConfigs,
+ onSelect,
+ }: {
+ multipleModelConfigs: Array<{ id: string }>
+ onSelect: (item: { id: string }) => void
+ }) => (
+
+ ),
+}))
+
+const createProps = (overrides: Partial = {}): React.ComponentProps => ({
+ debugWithMultipleModel: false,
+ draftUpdatedAt: 5678,
+ formatTimeFromNow: time => `from-now:${time}`,
+ isChatApp: false,
+ multipleModelConfigs: [{
+ id: 'model-1',
+ model: 'gpt-4o',
+ parameters: {},
+ provider: 'openai',
+ }] satisfies ModelAndParameter[],
+ onPublish: vi.fn(),
+ onRestore: vi.fn(),
+ publishDisabled: false,
+ published: false,
+ publishedAt: undefined,
+ publishLoading: false,
+ startNodeLimitExceeded: false,
+ upgradeHighlightStyle: { color: 'red' },
+ ...overrides,
+})
+
+describe('MenuContentPublishSection', () => {
+ it('should forward selected models when multiple-model publishing is enabled', async () => {
+ const user = userEvent.setup()
+ const onPublish = vi.fn()
+
+ render(
+ ,
+ )
+
+ await user.click(screen.getByText('publish-with-multiple-model'))
+
+ expect(onPublish).toHaveBeenCalledWith({ id: 'model-1' })
+ })
+})
diff --git a/web/app/components/app/app-publisher/__tests__/publish-with-multiple-model.spec.tsx b/web/app/components/app/app-publisher/__tests__/publish-with-multiple-model.spec.tsx
new file mode 100644
index 0000000000..a2f89ed513
--- /dev/null
+++ b/web/app/components/app/app-publisher/__tests__/publish-with-multiple-model.spec.tsx
@@ -0,0 +1,114 @@
+import type { ModelAndParameter } from '../../configuration/debug/types'
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import PublishWithMultipleModel from '../publish-with-multiple-model'
+
+let mockTextGenerationModelList: Array<{
+ provider: string
+ models: Array<{
+ model: string
+ label: Record
+ }>
+}>
+
+vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
+ PortalToFollowElem: ({
+ children,
+ open,
+ }: {
+ children: React.ReactNode
+ open: boolean
+ }) => {children}
,
+ PortalToFollowElemTrigger: ({
+ children,
+ onClick,
+ }: {
+ children: React.ReactNode
+ onClick?: () => void
+ }) => {children}
,
+ PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) => {children}
,
+}))
+
+vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
+ useLanguage: () => 'en_US',
+}))
+
+vi.mock('@/context/provider-context', () => ({
+ useProviderContext: () => ({
+ textGenerationModelList: mockTextGenerationModelList,
+ }),
+}))
+
+vi.mock('@/app/components/header/account-setting/model-provider-page/model-icon', () => ({
+ default: ({ modelName }: { modelName: string }) => {modelName}
,
+}))
+
+const validConfig: ModelAndParameter = {
+ id: 'config-1',
+ model: 'gpt-4o',
+ provider: 'openai',
+ parameters: {},
+}
+
+describe('PublishWithMultipleModel', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockTextGenerationModelList = [{
+ provider: 'openai',
+ models: [{
+ model: 'gpt-4o',
+ label: {
+ en_US: 'GPT-4o',
+ },
+ }],
+ }]
+ })
+
+ it('should disable the button when no valid model configuration matches the provider context', async () => {
+ const user = userEvent.setup()
+
+ render(
+ ,
+ )
+
+ const trigger = screen.getByRole('button', { name: 'appDebug.operation.applyConfig' })
+
+ expect(trigger).toBeDisabled()
+
+ await user.click(trigger)
+
+ expect(screen.getByTestId('portal')).toHaveAttribute('data-open', 'false')
+ })
+
+ it('should open the model list and forward the selected configuration', async () => {
+ const user = userEvent.setup()
+ const onSelect = vi.fn()
+
+ render(
+ ,
+ )
+
+ const trigger = screen.getByRole('button', { name: 'appDebug.operation.applyConfig' })
+
+ expect(trigger).not.toBeDisabled()
+
+ await user.click(trigger)
+
+ expect(screen.getByTestId('portal')).toHaveAttribute('data-open', 'true')
+ expect(screen.getByText('appDebug.publishAs')).toBeInTheDocument()
+ expect(screen.getByText('GPT-4o')).toBeInTheDocument()
+ expect(screen.getByTestId('model-icon-gpt-4o')).toBeInTheDocument()
+
+ await user.click(screen.getByText('GPT-4o'))
+
+ expect(onSelect).toHaveBeenCalledWith(expect.objectContaining(validConfig))
+ expect(screen.getByTestId('portal')).toHaveAttribute('data-open', 'false')
+ })
+})
diff --git a/web/app/components/app/app-publisher/__tests__/use-app-publisher.spec.tsx b/web/app/components/app/app-publisher/__tests__/use-app-publisher.spec.tsx
new file mode 100644
index 0000000000..7ca2e72444
--- /dev/null
+++ b/web/app/components/app/app-publisher/__tests__/use-app-publisher.spec.tsx
@@ -0,0 +1,486 @@
+import type { AppPublisherProps } from '../index'
+import type { AppDetailResponse } from '@/models/app'
+import type { SystemFeatures } from '@/types/feature'
+import { act, renderHook, waitFor } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { toast } from '@/app/components/base/ui/toast'
+import { WorkflowContext } from '@/app/components/workflow/context'
+import { AccessMode } from '@/models/access-control'
+import { AppModeEnum } from '@/types/app'
+import { defaultSystemFeatures } from '@/types/feature'
+import { basePath } from '@/utils/var'
+import { useAppPublisher } from '../use-app-publisher'
+
+const mockTrackEvent = vi.fn()
+const mockGetSocket = vi.fn()
+const mockRefetch = vi.fn()
+const mockFetchAppDetailDirect = vi.fn()
+const mockFetchInstalledAppList = vi.fn()
+const mockFetchPublishedWorkflow = vi.fn()
+const mockOpenAsyncWindow = vi.fn()
+const mockPublishToCreatorsPlatform = vi.fn()
+const mockInvalidateAppWorkflow = vi.fn()
+const mockSetAppDetail = vi.fn()
+const mockSetPublishedAt = vi.fn()
+const mockUnsubscribe = vi.fn()
+const mockWindowOpen = vi.fn()
+
+let capturedShortcut: ((event: { preventDefault: () => void }) => void) | undefined
+let capturedPublishUpdate: ((update: { data: { action?: string } }) => void) | undefined
+let mockAppDetail: AppDetailResponse | undefined
+let mockSystemFeatures: SystemFeatures
+let mockAccessSubjects: { groups?: Array<{ id: string }>, members?: Array<{ id: string }> } | undefined
+let mockUserCanAccessApp: { result?: boolean } | undefined
+let mockGetUserCanAccessAppLoading = false
+let mockAppWhiteListSubjectsLoading = false
+
+vi.mock('ahooks', () => ({
+ useKeyPress: (_key: string, callback: (event: { preventDefault: () => void }) => void) => {
+ capturedShortcut = callback
+ },
+}))
+
+vi.mock('@/app/components/base/amplitude', () => ({
+ trackEvent: (...args: unknown[]) => mockTrackEvent(...args),
+}))
+
+vi.mock('@/app/components/base/ui/toast', () => ({
+ toast: {
+ error: vi.fn(),
+ },
+}))
+
+vi.mock('@/app/components/workflow/collaboration/core/collaboration-manager', () => ({
+ collaborationManager: {
+ onAppPublishUpdate: (callback: (update: { data: { action?: string } }) => void) => {
+ capturedPublishUpdate = callback
+ return mockUnsubscribe
+ },
+ },
+}))
+
+vi.mock('@/app/components/workflow/collaboration/core/websocket-manager', () => ({
+ webSocketClient: {
+ getSocket: (...args: unknown[]) => mockGetSocket(...args),
+ },
+}))
+
+vi.mock('@/app/components/app/store', () => ({
+ useStore: (selector: (state: { appDetail?: AppDetailResponse, setAppDetail: typeof mockSetAppDetail }) => unknown) => selector({
+ appDetail: mockAppDetail,
+ setAppDetail: mockSetAppDetail,
+ }),
+}))
+
+vi.mock('@/context/global-public-context', () => ({
+ useGlobalPublicStore: (selector: (state: { systemFeatures: SystemFeatures }) => unknown) => selector({
+ systemFeatures: mockSystemFeatures,
+ }),
+}))
+
+vi.mock('@/hooks/use-async-window-open', () => ({
+ useAsyncWindowOpen: () => mockOpenAsyncWindow,
+}))
+
+vi.mock('@/hooks/use-format-time-from-now', () => ({
+ useFormatTimeFromNow: () => ({
+ formatTimeFromNow: (time: number) => `from-now:${time}`,
+ }),
+}))
+
+vi.mock('@/service/access-control', () => ({
+ useAppWhiteListSubjects: () => ({
+ data: mockAccessSubjects,
+ isLoading: mockAppWhiteListSubjectsLoading,
+ }),
+ useGetUserCanAccessApp: () => ({
+ data: mockUserCanAccessApp,
+ isLoading: mockGetUserCanAccessAppLoading,
+ refetch: mockRefetch,
+ }),
+}))
+
+vi.mock('@/service/apps', () => ({
+ fetchAppDetailDirect: (...args: unknown[]) => mockFetchAppDetailDirect(...args),
+}))
+
+vi.mock('@/service/client', () => ({
+ consoleClient: {
+ apps: {
+ publishToCreatorsPlatform: (...args: unknown[]) => mockPublishToCreatorsPlatform(...args),
+ },
+ },
+}))
+
+vi.mock('@/service/explore', () => ({
+ fetchInstalledAppList: (...args: unknown[]) => mockFetchInstalledAppList(...args),
+}))
+
+vi.mock('@/service/use-workflow', () => ({
+ useInvalidateAppWorkflow: () => mockInvalidateAppWorkflow,
+}))
+
+vi.mock('@/service/workflow', () => ({
+ fetchPublishedWorkflow: (...args: unknown[]) => mockFetchPublishedWorkflow(...args),
+}))
+
+const createSystemFeatures = (overrides: Partial = {}): SystemFeatures => ({
+ ...defaultSystemFeatures,
+ ...overrides,
+ branding: {
+ ...defaultSystemFeatures.branding,
+ ...overrides.branding,
+ },
+ license: {
+ ...defaultSystemFeatures.license,
+ ...overrides.license,
+ },
+ plugin_installation_permission: {
+ ...defaultSystemFeatures.plugin_installation_permission,
+ ...overrides.plugin_installation_permission,
+ },
+ webapp_auth: {
+ ...defaultSystemFeatures.webapp_auth,
+ ...overrides.webapp_auth,
+ sso_config: {
+ ...defaultSystemFeatures.webapp_auth.sso_config,
+ ...overrides.webapp_auth?.sso_config,
+ },
+ },
+})
+
+const createAppDetail = (overrides: Partial = {}): AppDetailResponse => ({
+ access_mode: AccessMode.PUBLIC,
+ description: 'Workflow description',
+ icon: '๐ค',
+ icon_background: '#ffffff',
+ icon_type: 'emoji',
+ id: 'app-1',
+ mode: AppModeEnum.WORKFLOW,
+ name: 'Workflow app',
+ site: {
+ app_base_url: 'https://apps.example.com',
+ access_token: 'app-token',
+ },
+ ...overrides,
+} as AppDetailResponse)
+
+const createProps = (overrides: Partial = {}): AppPublisherProps => ({
+ crossAxisOffset: 12,
+ debugWithMultipleModel: false,
+ draftUpdatedAt: 5678,
+ hasHumanInputNode: false,
+ hasTriggerNode: false,
+ inputs: [],
+ missingStartNode: false,
+ multipleModelConfigs: [],
+ onPublish: vi.fn(),
+ onRefreshData: vi.fn(),
+ onRestore: vi.fn(),
+ onToggle: vi.fn(),
+ outputs: [],
+ publishDisabled: false,
+ publishedAt: 1234,
+ publishLoading: false,
+ startNodeLimitExceeded: false,
+ toolPublished: false,
+ workflowToolAvailable: true,
+ ...overrides,
+})
+
+const createWrapper = () => {
+ const store = {
+ getState: () => ({
+ setPublishedAt: mockSetPublishedAt,
+ }),
+ } as unknown as NonNullable>
+
+ return ({ children }: { children: React.ReactNode }) => (
+
+ {children}
+
+ )
+}
+
+describe('useAppPublisher', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ capturedShortcut = undefined
+ capturedPublishUpdate = undefined
+ mockAppDetail = createAppDetail()
+ mockSystemFeatures = createSystemFeatures()
+ mockAccessSubjects = {
+ groups: [{ id: 'group-1' }],
+ members: [],
+ }
+ mockUserCanAccessApp = {
+ result: true,
+ }
+ mockGetUserCanAccessAppLoading = false
+ mockAppWhiteListSubjectsLoading = false
+ mockGetSocket.mockReturnValue({
+ emit: vi.fn(),
+ })
+ mockOpenAsyncWindow.mockImplementation(async (getUrl: () => Promise, options?: { onError?: (error: Error) => void }) => {
+ try {
+ return await getUrl()
+ }
+ catch (error) {
+ options?.onError?.(error as Error)
+ }
+ })
+ mockPublishToCreatorsPlatform.mockResolvedValue({
+ redirect_url: 'https://marketplace.example.com/app-1',
+ })
+ mockFetchInstalledAppList.mockResolvedValue({
+ installed_apps: [{ id: 'installed-1' }],
+ })
+ mockFetchAppDetailDirect.mockResolvedValue(createAppDetail({ name: 'Updated app' }))
+ mockFetchPublishedWorkflow.mockResolvedValue({
+ created_at: 4321,
+ })
+ window.open = mockWindowOpen as typeof window.open
+ })
+
+ it('should expose derived app metadata and default action state', () => {
+ const { result } = renderHook(() => useAppPublisher(createProps()), {
+ wrapper: createWrapper(),
+ })
+
+ expect(result.current.appURL).toBe(`https://apps.example.com${basePath}/workflow/app-token`)
+ expect(result.current.isChatApp).toBe(false)
+ expect(result.current.disabledFunctionButton).toBe(false)
+ expect(result.current.disabledFunctionTooltip).toBeUndefined()
+ expect(result.current.workflowToolDisabled).toBe(false)
+ expect(result.current.workflowToolMessage).toBeUndefined()
+ expect(result.current.formatTimeFromNow(1234)).toBe('from-now:1234')
+ })
+
+ it('should derive access warnings and refetch when the popover opens', async () => {
+ mockSystemFeatures = createSystemFeatures({
+ webapp_auth: {
+ ...defaultSystemFeatures.webapp_auth,
+ enabled: true,
+ },
+ })
+ mockAppDetail = createAppDetail({
+ access_mode: AccessMode.SPECIFIC_GROUPS_MEMBERS,
+ })
+ mockAccessSubjects = {
+ groups: [],
+ members: [],
+ }
+ mockUserCanAccessApp = {
+ result: false,
+ }
+ const onToggle = vi.fn()
+
+ const { result } = renderHook(() => useAppPublisher(createProps({ onToggle })), {
+ wrapper: createWrapper(),
+ })
+
+ expect(result.current.isAppAccessSet).toBe(false)
+ expect(result.current.disabledFunctionButton).toBe(true)
+ expect(result.current.disabledFunctionTooltip).toBe('app.noAccessPermission')
+
+ act(() => {
+ result.current.handleTrigger()
+ })
+
+ expect(result.current.open).toBe(true)
+ expect(onToggle).toHaveBeenCalledWith(true)
+ expect(mockRefetch).toHaveBeenCalledTimes(1)
+ })
+
+ it('should publish through the keyboard shortcut and emit collaboration updates', async () => {
+ const onPublish = vi.fn().mockResolvedValue(undefined)
+ const emit = vi.fn()
+ mockGetSocket.mockReturnValue({ emit })
+
+ const { result } = renderHook(() => useAppPublisher(createProps({ onPublish })), {
+ wrapper: createWrapper(),
+ })
+
+ await act(async () => {
+ await capturedShortcut?.({ preventDefault: vi.fn() })
+ })
+
+ expect(onPublish).toHaveBeenCalledTimes(1)
+ expect(result.current.published).toBe(true)
+ expect(mockInvalidateAppWorkflow).toHaveBeenCalledWith('app-1')
+ expect(emit).toHaveBeenCalledWith('collaboration_event', expect.objectContaining({
+ type: 'app_publish_update',
+ data: expect.objectContaining({ action: 'published' }),
+ }))
+ expect(mockTrackEvent).toHaveBeenCalledWith('app_published_time', expect.objectContaining({
+ app_id: 'app-1',
+ app_name: 'Workflow app',
+ }))
+ })
+
+ it('should keep the menu closed when restore finishes and swallow restore failures', async () => {
+ const onRestore = vi.fn()
+ .mockResolvedValueOnce(undefined)
+ .mockRejectedValueOnce(new Error('restore failed'))
+ const { result } = renderHook(() => useAppPublisher(createProps({ onRestore })), {
+ wrapper: createWrapper(),
+ })
+
+ act(() => {
+ result.current.handleTrigger()
+ })
+
+ await act(async () => {
+ await result.current.handleRestore()
+ })
+
+ expect(result.current.open).toBe(false)
+
+ act(() => {
+ result.current.handleTrigger()
+ })
+
+ await act(async () => {
+ await result.current.handleRestore()
+ })
+
+ expect(result.current.open).toBe(true)
+ })
+
+ it('should open the embedding modal, refresh app access, and close both overlays', async () => {
+ const { result } = renderHook(() => useAppPublisher(createProps()), {
+ wrapper: createWrapper(),
+ })
+
+ act(() => {
+ result.current.handleTrigger()
+ })
+
+ act(() => {
+ result.current.handleOpenEmbedding()
+ })
+
+ expect(result.current.embeddingModalOpen).toBe(true)
+ expect(result.current.open).toBe(false)
+
+ act(() => {
+ result.current.showAppAccessControlModal()
+ })
+
+ expect(result.current.showAppAccessControl).toBe(true)
+
+ await act(async () => {
+ await result.current.handleAccessControlUpdate()
+ })
+
+ expect(mockFetchAppDetailDirect).toHaveBeenCalledWith({ url: '/apps', id: 'app-1' })
+ expect(mockSetAppDetail).toHaveBeenCalledWith(expect.objectContaining({ name: 'Updated app' }))
+ expect(result.current.showAppAccessControl).toBe(false)
+
+ act(() => {
+ result.current.closeEmbeddingModal()
+ result.current.closeAppAccessControl()
+ })
+
+ expect(result.current.embeddingModalOpen).toBe(false)
+ expect(result.current.showAppAccessControl).toBe(false)
+ })
+
+ it('should resolve the explore URL and surface window-open errors via toast', async () => {
+ const { result } = renderHook(() => useAppPublisher(createProps()), {
+ wrapper: createWrapper(),
+ })
+
+ await act(async () => {
+ await result.current.handleOpenInExplore()
+ })
+
+ expect(mockFetchInstalledAppList).toHaveBeenCalledWith('app-1')
+
+ mockFetchInstalledAppList.mockResolvedValueOnce({ installed_apps: [] })
+
+ await act(async () => {
+ await result.current.handleOpenInExplore()
+ })
+
+ expect(toast.error).toHaveBeenCalledWith('No app found in Explore')
+ })
+
+ it('should publish to marketplace and reset loading after failures', async () => {
+ const { result } = renderHook(() => useAppPublisher(createProps()), {
+ wrapper: createWrapper(),
+ })
+
+ await act(async () => {
+ await result.current.handlePublishToMarketplace()
+ })
+
+ expect(mockPublishToCreatorsPlatform).toHaveBeenCalledWith({
+ params: { appId: 'app-1' },
+ })
+ expect(mockWindowOpen).toHaveBeenCalledWith('https://marketplace.example.com/app-1', '_blank')
+ expect(result.current.publishingToMarketplace).toBe(false)
+
+ mockPublishToCreatorsPlatform.mockRejectedValueOnce(new Error('publish failed'))
+
+ await act(async () => {
+ await result.current.handlePublishToMarketplace()
+ })
+
+ expect(toast.error).toHaveBeenCalledWith('publish failed')
+ expect(result.current.publishingToMarketplace).toBe(false)
+ })
+
+ it('should ignore marketplace publishing when the app id is missing', async () => {
+ mockAppDetail = createAppDetail({ id: '' })
+
+ const { result } = renderHook(() => useAppPublisher(createProps()), {
+ wrapper: createWrapper(),
+ })
+
+ await act(async () => {
+ await result.current.handlePublishToMarketplace()
+ })
+
+ expect(mockPublishToCreatorsPlatform).not.toHaveBeenCalled()
+ })
+
+ it('should refresh published workflow timestamps from collaboration events and unsubscribe on unmount', async () => {
+ const { unmount } = renderHook(() => useAppPublisher(createProps()), {
+ wrapper: createWrapper(),
+ })
+
+ await act(async () => {
+ capturedPublishUpdate?.({ data: { action: 'published' } })
+ })
+
+ await waitFor(() => {
+ expect(mockFetchPublishedWorkflow).toHaveBeenCalledWith('/apps/app-1/workflows/publish')
+ })
+
+ expect(mockSetPublishedAt).toHaveBeenCalledWith(4321)
+
+ unmount()
+
+ expect(mockUnsubscribe).toHaveBeenCalledTimes(1)
+ })
+
+ it('should warn when publish succeeds without an app id or socket', async () => {
+ mockAppDetail = createAppDetail({ id: '' })
+ mockGetSocket.mockReturnValue(null)
+ const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
+
+ const { result } = renderHook(() => useAppPublisher(createProps()), {
+ wrapper: createWrapper(),
+ })
+
+ await act(async () => {
+ await result.current.handlePublish()
+ })
+
+ expect(mockInvalidateAppWorkflow).not.toHaveBeenCalled()
+ expect(consoleWarnSpy).toHaveBeenCalledWith('[app-publisher] missing appId, skip workflow invalidate and socket emit')
+
+ consoleWarnSpy.mockRestore()
+ })
+})
diff --git a/web/app/components/app/app-publisher/index.tsx b/web/app/components/app/app-publisher/index.tsx
index fc23fbe080..baa17ac659 100644
--- a/web/app/components/app/app-publisher/index.tsx
+++ b/web/app/components/app/app-publisher/index.tsx
@@ -1,51 +1,18 @@
import type { ModelAndParameter } from '../configuration/debug/types'
-import type { CollaborationUpdate } from '@/app/components/workflow/collaboration/types/collaboration'
import type { InputVar, Variable } from '@/app/components/workflow/types'
-import type { InstalledApp } from '@/models/explore'
import type { PublishWorkflowParams } from '@/types/workflow'
-
-import { useKeyPress } from 'ahooks'
-import {
- memo,
- useCallback,
- useContext,
- useEffect,
- useMemo,
- useState,
-} from 'react'
+import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import EmbeddedModal from '@/app/components/app/overview/embedded'
-import { useStore as useAppStore } from '@/app/components/app/store'
-import { trackEvent } from '@/app/components/base/amplitude'
import Button from '@/app/components/base/button'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
-import { toast } from '@/app/components/base/ui/toast'
-import { collaborationManager } from '@/app/components/workflow/collaboration/core/collaboration-manager'
-import { webSocketClient } from '@/app/components/workflow/collaboration/core/websocket-manager'
-import { WorkflowContext } from '@/app/components/workflow/context'
-import { useGlobalPublicStore } from '@/context/global-public-context'
-import { useAsyncWindowOpen } from '@/hooks/use-async-window-open'
-import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
-import { AccessMode } from '@/models/access-control'
-import { useAppWhiteListSubjects, useGetUserCanAccessApp } from '@/service/access-control'
-import { fetchAppDetailDirect } from '@/service/apps'
-import { consoleClient } from '@/service/client'
-import { fetchInstalledAppList } from '@/service/explore'
-import { useInvalidateAppWorkflow } from '@/service/use-workflow'
-import { fetchPublishedWorkflow } from '@/service/workflow'
-import { AppModeEnum } from '@/types/app'
-import { basePath } from '@/utils/var'
-import { getKeyboardKeyCodeBySystem } from '../../workflow/utils'
import AccessControl from '../app-access-control'
import AppPublisherMenuContent from './menu-content'
-
-type InstalledAppsResponse = {
- installed_apps?: InstalledApp[]
-}
+import { useAppPublisher } from './use-app-publisher'
export type AppPublisherProps = {
disabled?: boolean
@@ -95,219 +62,46 @@ const AppPublisher = ({
hasHumanInputNode = false,
}: AppPublisherProps) => {
const { t } = useTranslation()
-
- const [published, setPublished] = useState(false)
- const [open, setOpen] = useState(false)
- const [showAppAccessControl, setShowAppAccessControl] = useState(false)
-
- const [embeddingModalOpen, setEmbeddingModalOpen] = useState(false)
- const [publishingToMarketplace, setPublishingToMarketplace] = useState(false)
-
- const workflowStore = useContext(WorkflowContext)
- const appDetail = useAppStore(state => state.appDetail)
- const setAppDetail = useAppStore(s => s.setAppDetail)
- const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
- const { formatTimeFromNow } = useFormatTimeFromNow()
- const { app_base_url: appBaseURL = '', access_token: accessToken = '' } = appDetail?.site ?? {}
-
- const appMode = (appDetail?.mode !== AppModeEnum.COMPLETION && appDetail?.mode !== AppModeEnum.WORKFLOW) ? AppModeEnum.CHAT : appDetail.mode
- const appURL = `${appBaseURL}${basePath}/${appMode}/${accessToken}`
- const isChatApp = [AppModeEnum.CHAT, AppModeEnum.AGENT_CHAT, AppModeEnum.COMPLETION].includes(appDetail?.mode || AppModeEnum.CHAT)
-
- const { data: userCanAccessApp, isLoading: isGettingUserCanAccessApp, refetch } = useGetUserCanAccessApp({ appId: appDetail?.id, enabled: false })
- const { data: appAccessSubjects, isLoading: isGettingAppWhiteListSubjects } = useAppWhiteListSubjects(appDetail?.id, open && systemFeatures.webapp_auth.enabled && appDetail?.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS)
- const invalidateAppWorkflow = useInvalidateAppWorkflow()
- const openAsyncWindow = useAsyncWindowOpen()
-
- const isAppAccessSet = useMemo(() => {
- if (appDetail && appAccessSubjects) {
- return !(appDetail.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS && appAccessSubjects.groups?.length === 0 && appAccessSubjects.members?.length === 0)
- }
- return true
- }, [appAccessSubjects, appDetail])
-
- const noAccessPermission = useMemo(() => Boolean(
- systemFeatures.webapp_auth.enabled
- && appDetail
- && appDetail.access_mode !== AccessMode.EXTERNAL_MEMBERS
- && !userCanAccessApp?.result,
- ), [systemFeatures, appDetail, userCanAccessApp])
- const disabledFunctionButton = useMemo(() => (!publishedAt || missingStartNode || noAccessPermission), [publishedAt, missingStartNode, noAccessPermission])
-
- const disabledFunctionTooltip = useMemo(() => {
- if (!publishedAt)
- return t('notPublishedYet', { ns: 'app' })
- if (missingStartNode)
- return t('noUserInputNode', { ns: 'app' })
- if (noAccessPermission)
- return t('noAccessPermission', { ns: 'app' })
- }, [missingStartNode, noAccessPermission, publishedAt, t])
-
- useEffect(() => {
- if (systemFeatures.webapp_auth.enabled && open && appDetail)
- refetch()
- }, [open, appDetail, refetch, systemFeatures])
-
- const handlePublish = useCallback(async (params?: ModelAndParameter | PublishWorkflowParams) => {
- try {
- await onPublish?.(params)
- setPublished(true)
-
- const appId = appDetail?.id
- const socket = appId ? webSocketClient.getSocket(appId) : null
- if (appId)
- invalidateAppWorkflow(appId)
- else
- console.warn('[app-publisher] missing appId, skip workflow invalidate and socket emit')
- if (socket) {
- const timestamp = Date.now()
- socket.emit('collaboration_event', {
- type: 'app_publish_update',
- data: {
- action: 'published',
- timestamp,
- },
- timestamp,
- })
- }
- else if (appId) {
- console.warn('[app-publisher] socket not ready, skip collaboration_event emit', { appId })
- }
-
- trackEvent('app_published_time', { action_mode: 'app', app_id: appDetail?.id, app_name: appDetail?.name })
- }
- catch (error) {
- console.warn('[app-publisher] publish failed', error)
- setPublished(false)
- }
- }, [appDetail, onPublish, invalidateAppWorkflow])
-
- const handleRestore = useCallback(async () => {
- try {
- await onRestore?.()
- setOpen(false)
- }
- catch { }
- }, [onRestore])
-
- const handleTrigger = useCallback(() => {
- const state = !open
-
- if (disabled) {
- setOpen(false)
- return
- }
-
- onToggle?.(state)
- setOpen(state)
-
- if (state)
- setPublished(false)
- }, [disabled, onToggle, open])
-
- const handleOpenInExplore = useCallback(async () => {
- await openAsyncWindow(async () => {
- if (!appDetail?.id)
- throw new Error('App not found')
- const response = (await fetchInstalledAppList(appDetail?.id)) as InstalledAppsResponse
- const installedApps = response?.installed_apps
- if (installedApps?.length)
- return `${basePath}/explore/installed/${installedApps[0].id}`
- throw new Error('No app found in Explore')
- }, {
- onError: (err) => {
- toast.error(`${err.message || err}`)
- },
- })
- }, [appDetail?.id, openAsyncWindow])
-
- const handleAccessControlUpdate = useCallback(async () => {
- if (!appDetail)
- return
- try {
- const res = await fetchAppDetailDirect({ url: '/apps', id: appDetail.id })
- setAppDetail(res)
- }
- finally {
- setShowAppAccessControl(false)
- }
- }, [appDetail, setAppDetail])
-
- const handlePublishToMarketplace = useCallback(async () => {
- if (!appDetail?.id || publishingToMarketplace)
- return
- setPublishingToMarketplace(true)
- try {
- const result = await consoleClient.apps.publishToCreatorsPlatform({
- params: { appId: appDetail.id },
- })
- window.open(result.redirect_url, '_blank')
- }
- catch (error: any) {
- toast.error(error.message || t('common.publishToMarketplaceFailed', { ns: 'workflow' }))
- }
- finally {
- setPublishingToMarketplace(false)
- }
- }, [appDetail?.id, publishingToMarketplace, t])
-
- useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.shift.p`, (e) => {
- e.preventDefault()
- if (publishDisabled || published || publishLoading)
- return
- handlePublish()
- }, { exactMatch: true, useCapture: true })
-
- useEffect(() => {
- const appId = appDetail?.id
- if (!appId)
- return
-
- const unsubscribe = collaborationManager.onAppPublishUpdate((update: CollaborationUpdate) => {
- const action = typeof update.data.action === 'string' ? update.data.action : undefined
- if (action === 'published') {
- invalidateAppWorkflow(appId)
- fetchPublishedWorkflow(`/apps/${appId}/workflows/publish`)
- .then((publishedWorkflow) => {
- if (publishedWorkflow?.created_at)
- workflowStore?.getState().setPublishedAt(publishedWorkflow.created_at)
- })
- .catch((error) => {
- console.warn('[app-publisher] refresh published workflow failed', error)
- })
- }
- })
-
- return unsubscribe
- }, [appDetail?.id, invalidateAppWorkflow, workflowStore])
-
- const hasPublishedVersion = !!publishedAt
- const workflowToolDisabled = !hasPublishedVersion || !workflowToolAvailable
- const workflowToolMessage = workflowToolDisabled ? t('common.workflowAsToolDisabledHint', { ns: 'workflow' }) : undefined
- const upgradeHighlightStyle = useMemo(() => ({
- background: 'linear-gradient(97deg, var(--components-input-border-active-prompt-1, rgba(11, 165, 236, 0.95)) -3.64%, var(--components-input-border-active-prompt-2, rgba(21, 90, 239, 0.95)) 45.14%)',
- WebkitBackgroundClip: 'text',
- backgroundClip: 'text',
- WebkitTextFillColor: 'transparent',
- }), [])
+ const state = useAppPublisher({
+ disabled,
+ publishDisabled,
+ publishedAt,
+ draftUpdatedAt,
+ debugWithMultipleModel,
+ multipleModelConfigs,
+ onPublish,
+ onRestore,
+ onToggle,
+ crossAxisOffset,
+ toolPublished,
+ inputs,
+ outputs,
+ onRefreshData,
+ workflowToolAvailable,
+ missingStartNode,
+ hasTriggerNode,
+ startNodeLimitExceeded,
+ publishLoading,
+ hasHumanInputNode,
+ })
return (
<>
-
+
{
- setEmbeddingModalOpen(true)
- handleTrigger()
- }}
- onOpenInExplore={handleOpenInExplore}
- onPublish={handlePublish}
- onPublishToMarketplace={handlePublishToMarketplace}
- onRestore={handleRestore}
- onShowAppAccessControl={() => setShowAppAccessControl(true)}
- published={published}
- publishingToMarketplace={publishingToMarketplace}
- systemFeatures={systemFeatures}
- upgradeHighlightStyle={upgradeHighlightStyle}
- workflowToolDisabled={workflowToolDisabled}
- workflowToolMessage={workflowToolMessage}
+ publishedAt={state.publishedAt}
+ draftUpdatedAt={state.draftUpdatedAt}
+ debugWithMultipleModel={state.debugWithMultipleModel}
+ multipleModelConfigs={state.multipleModelConfigs}
+ publishDisabled={state.publishDisabled}
+ publishLoading={state.publishLoading}
+ toolPublished={state.toolPublished}
+ inputs={state.inputs}
+ outputs={state.outputs}
+ onRefreshData={state.onRefreshData}
+ workflowToolAvailable={state.workflowToolAvailable}
+ hasTriggerNode={state.hasTriggerNode}
+ missingStartNode={state.missingStartNode}
+ startNodeLimitExceeded={state.startNodeLimitExceeded}
+ hasHumanInputNode={state.hasHumanInputNode}
+ appDetail={state.appDetail}
+ appURL={state.appURL}
+ disabledFunctionButton={state.disabledFunctionButton}
+ disabledFunctionTooltip={state.disabledFunctionTooltip}
+ formatTimeFromNow={state.formatTimeFromNow}
+ isAppAccessSet={state.isAppAccessSet}
+ isChatApp={state.isChatApp}
+ isGettingAppWhiteListSubjects={state.isGettingAppWhiteListSubjects}
+ isGettingUserCanAccessApp={state.isGettingUserCanAccessApp}
+ onOpenEmbedding={state.handleOpenEmbedding}
+ onOpenInExplore={state.handleOpenInExplore}
+ onPublish={state.handlePublish}
+ onPublishToMarketplace={state.handlePublishToMarketplace}
+ onRestore={state.handleRestore}
+ onShowAppAccessControl={state.showAppAccessControlModal}
+ published={state.published}
+ publishingToMarketplace={state.publishingToMarketplace}
+ systemFeatures={state.systemFeatures}
+ upgradeHighlightStyle={state.upgradeHighlightStyle}
+ workflowToolDisabled={state.workflowToolDisabled}
+ workflowToolMessage={state.workflowToolMessage}
/>
setEmbeddingModalOpen(false)}
- appBaseUrl={appBaseURL}
- accessToken={accessToken}
+ siteInfo={state.appDetail?.site}
+ isShow={state.embeddingModalOpen}
+ onClose={state.closeEmbeddingModal}
+ appBaseUrl={state.appBaseURL}
+ accessToken={state.accessToken}
/>
- {showAppAccessControl && { setShowAppAccessControl(false) }} />}
+ {state.showAppAccessControl && state.appDetail && (
+
+ )}
>
)
diff --git a/web/app/components/app/app-publisher/use-app-publisher.ts b/web/app/components/app/app-publisher/use-app-publisher.ts
new file mode 100644
index 0000000000..1a2130a097
--- /dev/null
+++ b/web/app/components/app/app-publisher/use-app-publisher.ts
@@ -0,0 +1,368 @@
+'use client'
+
+import type { CSSProperties } from 'react'
+import type { ModelAndParameter } from '../configuration/debug/types'
+import type { AppPublisherProps } from './index'
+import type { CollaborationUpdate } from '@/app/components/workflow/collaboration/types/collaboration'
+import type { InstalledApp } from '@/models/explore'
+import type { PublishWorkflowParams } from '@/types/workflow'
+import { useKeyPress } from 'ahooks'
+import { use, useCallback, useEffect, useMemo, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import { useStore as useAppStore } from '@/app/components/app/store'
+import { trackEvent } from '@/app/components/base/amplitude'
+import { toast } from '@/app/components/base/ui/toast'
+import { collaborationManager } from '@/app/components/workflow/collaboration/core/collaboration-manager'
+import { webSocketClient } from '@/app/components/workflow/collaboration/core/websocket-manager'
+import { WorkflowContext } from '@/app/components/workflow/context'
+import { useGlobalPublicStore } from '@/context/global-public-context'
+import { useAsyncWindowOpen } from '@/hooks/use-async-window-open'
+import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
+import { AccessMode } from '@/models/access-control'
+import { useAppWhiteListSubjects, useGetUserCanAccessApp } from '@/service/access-control'
+import { fetchAppDetailDirect } from '@/service/apps'
+import { consoleClient } from '@/service/client'
+import { fetchInstalledAppList } from '@/service/explore'
+import { useInvalidateAppWorkflow } from '@/service/use-workflow'
+import { fetchPublishedWorkflow } from '@/service/workflow'
+import { AppModeEnum } from '@/types/app'
+import { basePath } from '@/utils/var'
+import { getKeyboardKeyCodeBySystem } from '../../workflow/utils'
+
+type InstalledAppsResponse = {
+ installed_apps?: InstalledApp[]
+}
+
+const upgradeHighlightStyle: CSSProperties = {
+ background: 'linear-gradient(97deg, var(--components-input-border-active-prompt-1, rgba(11, 165, 236, 0.95)) -3.64%, var(--components-input-border-active-prompt-2, rgba(21, 90, 239, 0.95)) 45.14%)',
+ WebkitBackgroundClip: 'text',
+ backgroundClip: 'text',
+ WebkitTextFillColor: 'transparent',
+}
+
+export const useAppPublisher = ({
+ disabled = false,
+ publishDisabled = false,
+ publishedAt,
+ draftUpdatedAt,
+ debugWithMultipleModel = false,
+ multipleModelConfigs = [],
+ onPublish,
+ onRestore,
+ onToggle,
+ crossAxisOffset = 0,
+ toolPublished,
+ inputs,
+ outputs,
+ onRefreshData,
+ workflowToolAvailable = true,
+ missingStartNode = false,
+ hasTriggerNode = false,
+ startNodeLimitExceeded = false,
+ publishLoading = false,
+ hasHumanInputNode = false,
+}: AppPublisherProps) => {
+ const { t } = useTranslation()
+
+ const [published, setPublished] = useState(false)
+ const [open, setOpen] = useState(false)
+ const [showAppAccessControl, setShowAppAccessControl] = useState(false)
+ const [embeddingModalOpen, setEmbeddingModalOpen] = useState(false)
+ const [publishingToMarketplace, setPublishingToMarketplace] = useState(false)
+
+ const workflowStore = use(WorkflowContext)
+ const appDetail = useAppStore(state => state.appDetail)
+ const setAppDetail = useAppStore(state => state.setAppDetail)
+ const systemFeatures = useGlobalPublicStore(state => state.systemFeatures)
+ const { formatTimeFromNow } = useFormatTimeFromNow()
+ const openAsyncWindow = useAsyncWindowOpen()
+ const invalidateAppWorkflow = useInvalidateAppWorkflow()
+
+ const { app_base_url: appBaseURL = '', access_token: accessToken = '' } = appDetail?.site ?? {}
+ const appMode = appDetail?.mode !== AppModeEnum.COMPLETION && appDetail?.mode !== AppModeEnum.WORKFLOW
+ ? AppModeEnum.CHAT
+ : appDetail?.mode
+ const appURL = `${appBaseURL}${basePath}/${appMode}/${accessToken}`
+ const isChatApp = [
+ AppModeEnum.CHAT,
+ AppModeEnum.AGENT_CHAT,
+ AppModeEnum.COMPLETION,
+ ].includes(appDetail?.mode || AppModeEnum.CHAT)
+
+ const {
+ data: userCanAccessApp,
+ isLoading: isGettingUserCanAccessApp,
+ refetch,
+ } = useGetUserCanAccessApp({ appId: appDetail?.id, enabled: false })
+
+ const {
+ data: appAccessSubjects,
+ isLoading: isGettingAppWhiteListSubjects,
+ } = useAppWhiteListSubjects(
+ appDetail?.id,
+ open
+ && systemFeatures.webapp_auth.enabled
+ && appDetail?.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS,
+ )
+
+ const isAppAccessSet = useMemo(() => {
+ if (appDetail && appAccessSubjects) {
+ return !(
+ appDetail.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS
+ && appAccessSubjects.groups?.length === 0
+ && appAccessSubjects.members?.length === 0
+ )
+ }
+
+ return true
+ }, [appAccessSubjects, appDetail])
+
+ const noAccessPermission = useMemo(() => Boolean(
+ systemFeatures.webapp_auth.enabled
+ && appDetail
+ && appDetail.access_mode !== AccessMode.EXTERNAL_MEMBERS
+ && !userCanAccessApp?.result,
+ ), [systemFeatures, appDetail, userCanAccessApp])
+
+ const disabledFunctionButton = !publishedAt || missingStartNode || noAccessPermission
+ const disabledFunctionTooltip = !publishedAt
+ ? t('notPublishedYet', { ns: 'app' })
+ : missingStartNode
+ ? t('noUserInputNode', { ns: 'app' })
+ : noAccessPermission
+ ? t('noAccessPermission', { ns: 'app' })
+ : undefined
+
+ const workflowToolDisabled = !publishedAt || !workflowToolAvailable
+ const workflowToolMessage = workflowToolDisabled
+ ? t('common.workflowAsToolDisabledHint', { ns: 'workflow' })
+ : undefined
+
+ useEffect(() => {
+ if (systemFeatures.webapp_auth.enabled && open && appDetail)
+ refetch()
+ }, [appDetail, open, refetch, systemFeatures])
+
+ const handlePublish = useCallback(async (params?: ModelAndParameter | PublishWorkflowParams) => {
+ try {
+ await onPublish?.(params)
+ setPublished(true)
+
+ const appId = appDetail?.id
+ const socket = appId ? webSocketClient.getSocket(appId) : null
+
+ if (appId)
+ invalidateAppWorkflow(appId)
+ else
+ console.warn('[app-publisher] missing appId, skip workflow invalidate and socket emit')
+
+ if (socket) {
+ const timestamp = Date.now()
+ socket.emit('collaboration_event', {
+ type: 'app_publish_update',
+ data: {
+ action: 'published',
+ timestamp,
+ },
+ timestamp,
+ })
+ }
+ else if (appId) {
+ console.warn('[app-publisher] socket not ready, skip collaboration_event emit', { appId })
+ }
+
+ trackEvent('app_published_time', {
+ action_mode: 'app',
+ app_id: appDetail?.id,
+ app_name: appDetail?.name,
+ })
+ }
+ catch (error) {
+ console.warn('[app-publisher] publish failed', error)
+ setPublished(false)
+ }
+ }, [appDetail, invalidateAppWorkflow, onPublish])
+
+ const handleRestore = useCallback(async () => {
+ try {
+ await onRestore?.()
+ setOpen(false)
+ }
+ catch {}
+ }, [onRestore])
+
+ const handleTrigger = useCallback(() => {
+ const nextOpen = !open
+
+ if (disabled) {
+ setOpen(false)
+ return
+ }
+
+ onToggle?.(nextOpen)
+ setOpen(nextOpen)
+
+ if (nextOpen)
+ setPublished(false)
+ }, [disabled, onToggle, open])
+
+ const handleOpenEmbedding = useCallback(() => {
+ setEmbeddingModalOpen(true)
+ handleTrigger()
+ }, [handleTrigger])
+
+ const handleOpenInExplore = useCallback(async () => {
+ await openAsyncWindow(async () => {
+ if (!appDetail?.id)
+ throw new Error('App not found')
+
+ const response = await fetchInstalledAppList(appDetail.id) as InstalledAppsResponse
+ const installedApps = response?.installed_apps
+
+ if (installedApps?.length)
+ return `${basePath}/explore/installed/${installedApps[0].id}`
+
+ throw new Error('No app found in Explore')
+ }, {
+ onError: (error) => {
+ toast.error(`${error.message || error}`)
+ },
+ })
+ }, [appDetail?.id, openAsyncWindow])
+
+ const handleAccessControlUpdate = useCallback(async () => {
+ if (!appDetail)
+ return
+
+ try {
+ const nextAppDetail = await fetchAppDetailDirect({ url: '/apps', id: appDetail.id })
+ setAppDetail(nextAppDetail)
+ }
+ finally {
+ setShowAppAccessControl(false)
+ }
+ }, [appDetail, setAppDetail])
+
+ const handlePublishToMarketplace = useCallback(async () => {
+ if (!appDetail?.id || publishingToMarketplace)
+ return
+
+ setPublishingToMarketplace(true)
+
+ try {
+ const result = await consoleClient.apps.publishToCreatorsPlatform({
+ params: { appId: appDetail.id },
+ })
+
+ window.open(result.redirect_url, '_blank')
+ }
+ catch (error) {
+ const errorMessage = typeof error === 'object'
+ && error !== null
+ && 'message' in error
+ && typeof error.message === 'string'
+ ? error.message
+ : t('common.publishToMarketplaceFailed', { ns: 'workflow' })
+
+ toast.error(errorMessage)
+ }
+ finally {
+ setPublishingToMarketplace(false)
+ }
+ }, [appDetail?.id, publishingToMarketplace, t])
+
+ const closeEmbeddingModal = useCallback(() => {
+ setEmbeddingModalOpen(false)
+ }, [])
+
+ const showAppAccessControlModal = useCallback(() => {
+ setShowAppAccessControl(true)
+ }, [])
+
+ const closeAppAccessControl = useCallback(() => {
+ setShowAppAccessControl(false)
+ }, [])
+
+ useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.shift.p`, (event) => {
+ event.preventDefault()
+
+ if (publishDisabled || published || publishLoading)
+ return
+
+ handlePublish()
+ }, { exactMatch: true, useCapture: true })
+
+ useEffect(() => {
+ const appId = appDetail?.id
+ if (!appId)
+ return
+
+ const unsubscribe = collaborationManager.onAppPublishUpdate((update: CollaborationUpdate) => {
+ const action = typeof update.data.action === 'string' ? update.data.action : undefined
+ if (action === 'published') {
+ invalidateAppWorkflow(appId)
+ fetchPublishedWorkflow(`/apps/${appId}/workflows/publish`)
+ .then((publishedWorkflow) => {
+ if (publishedWorkflow?.created_at)
+ workflowStore?.getState().setPublishedAt(publishedWorkflow.created_at)
+ })
+ .catch((error) => {
+ console.warn('[app-publisher] refresh published workflow failed', error)
+ })
+ }
+ })
+
+ return unsubscribe
+ }, [appDetail?.id, invalidateAppWorkflow, workflowStore])
+
+ return {
+ accessToken,
+ appBaseURL,
+ appDetail,
+ appURL,
+ closeAppAccessControl,
+ closeEmbeddingModal,
+ crossAxisOffset,
+ debugWithMultipleModel,
+ disabled,
+ disabledFunctionButton,
+ disabledFunctionTooltip,
+ draftUpdatedAt,
+ embeddingModalOpen,
+ formatTimeFromNow,
+ handleAccessControlUpdate,
+ handleOpenEmbedding,
+ handleOpenInExplore,
+ handlePublish,
+ handlePublishToMarketplace,
+ handleRestore,
+ handleTrigger,
+ hasHumanInputNode,
+ hasTriggerNode,
+ inputs,
+ isAppAccessSet,
+ isChatApp,
+ isGettingAppWhiteListSubjects,
+ isGettingUserCanAccessApp,
+ missingStartNode,
+ multipleModelConfigs,
+ onRefreshData,
+ open,
+ outputs,
+ publishDisabled,
+ publishLoading,
+ published,
+ publishedAt,
+ publishingToMarketplace,
+ setOpen,
+ showAppAccessControl,
+ showAppAccessControlModal,
+ startNodeLimitExceeded,
+ systemFeatures,
+ toolPublished,
+ upgradeHighlightStyle,
+ workflowToolAvailable,
+ workflowToolDisabled,
+ workflowToolMessage,
+ }
+}
diff --git a/web/app/components/app/configuration/tools/__tests__/external-data-tool-modal.spec.tsx b/web/app/components/app/configuration/tools/__tests__/external-data-tool-modal.spec.tsx
new file mode 100644
index 0000000000..7975d11768
--- /dev/null
+++ b/web/app/components/app/configuration/tools/__tests__/external-data-tool-modal.spec.tsx
@@ -0,0 +1,237 @@
+import type { ExternalDataTool } from '@/models/common'
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { toast } from '@/app/components/base/ui/toast'
+import ExternalDataToolModal from '../external-data-tool-modal'
+
+let mockLocale = 'en-US'
+
+vi.mock('@/app/components/base/app-icon', () => ({
+ default: ({
+ icon,
+ onClick,
+ }: {
+ icon?: string
+ onClick?: () => void
+ }) => (
+
+ ),
+}))
+
+vi.mock('@/app/components/base/emoji-picker', () => ({
+ default: ({
+ onClose,
+ onSelect,
+ }: {
+ onClose: () => void
+ onSelect: (icon: string, iconBackground: string) => void
+ }) => (
+
+
+
+
+ ),
+}))
+
+vi.mock('@/app/components/base/features/new-feature-panel/moderation/form-generation', () => ({
+ default: ({
+ onChange,
+ }: {
+ onChange: (value: Record) => void
+ }) => ,
+}))
+
+vi.mock('@/app/components/base/ui/dialog', () => ({
+ Dialog: ({ children }: { children: React.ReactNode }) => {children}
,
+ DialogContent: ({ children }: { children: React.ReactNode }) => {children}
,
+}))
+
+vi.mock('@/app/components/base/ui/select', () => ({
+ Select: ({
+ children,
+ onValueChange,
+ }: {
+ children: React.ReactNode
+ onValueChange?: (value: string) => void
+ }) => (
+
+ {children}
+
+
+
+ ),
+ SelectContent: ({ children }: { children: React.ReactNode }) => {children}
,
+ SelectItem: ({ children }: { children: React.ReactNode }) => {children}
,
+ SelectTrigger: ({
+ children,
+ ...props
+ }: React.ButtonHTMLAttributes) => ,
+ SelectValue: () => select-value,
+}))
+
+vi.mock('@/app/components/base/ui/toast', () => ({
+ toast: {
+ error: vi.fn(),
+ },
+}))
+
+vi.mock('@/app/components/header/account-setting/api-based-extension-page/selector', () => ({
+ default: ({ onChange }: { onChange: (value: string) => void }) => (
+
+ ),
+}))
+
+vi.mock('@/context/i18n', () => ({
+ useDocLink: () => (path: string) => `https://docs.example.com${path}`,
+ useLocale: () => mockLocale,
+}))
+
+vi.mock('@/service/use-common', () => ({
+ useCodeBasedExtensions: () => ({
+ data: {
+ data: [{
+ form_schema: [{
+ default: 'default-region',
+ label: {
+ 'en-US': 'Region',
+ 'zh-Hans': 'ๅฐๅบ',
+ },
+ required: true,
+ variable: 'region',
+ }],
+ label: {
+ 'en-US': 'Custom Tool',
+ 'zh-Hans': '่ชๅฎไนๅทฅๅ
ท',
+ },
+ name: 'custom-tool',
+ }],
+ },
+ }),
+}))
+
+describe('ExternalDataToolModal', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockLocale = 'en-US'
+ })
+
+ it('should save api-based tools after validating the selected extension', async () => {
+ const user = userEvent.setup()
+ const onSave = vi.fn()
+ const onValidateBeforeSave = vi.fn().mockReturnValue(true)
+
+ render(
+ ,
+ )
+
+ expect(screen.getByRole('link', { name: 'common.apiBasedExtension.link' })).toHaveAttribute(
+ 'href',
+ 'https://docs.example.com/use-dify/workspace/api-extension/api-extension',
+ )
+
+ await user.type(screen.getByPlaceholderText('appDebug.feature.tools.modal.name.placeholder'), 'Search tool')
+ await user.type(screen.getByPlaceholderText('appDebug.feature.tools.modal.variableName.placeholder'), 'search_tool')
+ await user.click(screen.getByText('select-api-extension'))
+ await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
+
+ expect(onValidateBeforeSave).toHaveBeenCalledWith(expect.objectContaining({
+ config: {
+ api_based_extension_id: 'extension-1',
+ },
+ enabled: true,
+ label: 'Search tool',
+ type: 'api',
+ variable: 'search_tool',
+ }))
+ expect(onSave).toHaveBeenCalledWith(expect.objectContaining({
+ config: {
+ api_based_extension_id: 'extension-1',
+ },
+ enabled: true,
+ label: 'Search tool',
+ type: 'api',
+ variable: 'search_tool',
+ }))
+ })
+
+ it('should reject invalid variable names before save', async () => {
+ const user = userEvent.setup()
+
+ render(
+ ,
+ )
+
+ await user.type(screen.getByPlaceholderText('appDebug.feature.tools.modal.name.placeholder'), 'Search tool')
+ await user.type(screen.getByPlaceholderText('appDebug.feature.tools.modal.variableName.placeholder'), 'invalid-key!')
+ await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
+
+ expect(toast.error).toHaveBeenCalledWith(expect.stringContaining('notValid'))
+ })
+
+ it('should allow selecting emojis and saving custom providers with generated form data', async () => {
+ const user = userEvent.setup()
+ const onSave = vi.fn()
+
+ render(
+ ,
+ )
+
+ await user.click(screen.getByText('select-custom-tool'))
+ await user.type(screen.getByPlaceholderText('appDebug.feature.tools.modal.name.placeholder'), 'Custom tool')
+ await user.type(screen.getByPlaceholderText('appDebug.feature.tools.modal.variableName.placeholder'), 'custom_tool')
+ await user.click(screen.getByTestId('app-icon'))
+ await user.click(screen.getByText('select-emoji'))
+ await user.click(screen.getByText('fill-form'))
+ await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
+
+ expect(onSave).toHaveBeenCalledWith(expect.objectContaining({
+ config: {
+ region: 'us',
+ },
+ enabled: true,
+ icon: '๐',
+ icon_background: '#000000',
+ type: 'custom-tool',
+ variable: 'custom_tool',
+ }))
+ })
+
+ it('should stop before saving when the caller rejects the formatted payload', async () => {
+ const user = userEvent.setup()
+ const onSave = vi.fn()
+ const onValidateBeforeSave = vi.fn().mockReturnValue(false)
+
+ render(
+ ,
+ )
+
+ await user.type(screen.getByPlaceholderText('appDebug.feature.tools.modal.name.placeholder'), 'Search tool')
+ await user.type(screen.getByPlaceholderText('appDebug.feature.tools.modal.variableName.placeholder'), 'search_tool')
+ await user.click(screen.getByText('select-api-extension'))
+ await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
+
+ expect(onValidateBeforeSave).toHaveBeenCalledTimes(1)
+ expect(onSave).not.toHaveBeenCalled()
+ })
+})
diff --git a/web/app/components/app/configuration/tools/__tests__/helpers.spec.ts b/web/app/components/app/configuration/tools/__tests__/helpers.spec.ts
new file mode 100644
index 0000000000..84f35b9f86
--- /dev/null
+++ b/web/app/components/app/configuration/tools/__tests__/helpers.spec.ts
@@ -0,0 +1,190 @@
+import type { ExternalDataToolProvider } from '../helpers'
+import type { CodeBasedExtensionForm, ExternalDataTool } from '@/models/common'
+import { describe, expect, it } from 'vitest'
+import { LanguagesSupported } from '@/i18n-config/language'
+import {
+
+ findExternalDataToolVariableConflict,
+ formatExternalDataToolForSave,
+ getExternalDataToolDefaultConfig,
+ getExternalDataToolValidationError,
+ getInitialExternalDataTool,
+ removeExternalDataTool,
+ upsertExternalDataTool,
+} from '../helpers'
+
+const createProviderField = (overrides: Partial = {}): CodeBasedExtensionForm => ({
+ default: 'default-region',
+ label: {
+ 'en-US': 'Region',
+ 'zh-Hans': 'ๅฐๅบ',
+ } as CodeBasedExtensionForm['label'],
+ options: [],
+ placeholder: '',
+ required: true,
+ type: 'text-input',
+ variable: 'region',
+ ...overrides,
+})
+
+const provider: ExternalDataToolProvider = {
+ key: 'custom-tool',
+ name: 'Custom tool',
+ form_schema: [createProviderField()],
+}
+
+const createTool = (overrides: Partial = {}): ExternalDataTool => ({
+ config: {},
+ icon: '๐ค',
+ icon_background: '#fff',
+ label: 'External tool',
+ type: 'api',
+ variable: 'tool_var',
+ ...overrides,
+})
+
+describe('configuration/tools/helpers', () => {
+ it('should initialize new tools with the api type', () => {
+ expect(getInitialExternalDataTool({} as ExternalDataTool)).toEqual({
+ type: 'api',
+ })
+ expect(getInitialExternalDataTool(createTool({ type: 'custom-tool' }))).toEqual(createTool({ type: 'custom-tool' }))
+ })
+
+ it('should derive default configs from non-system providers only', () => {
+ expect(getExternalDataToolDefaultConfig('api', [provider])).toBeUndefined()
+ expect(getExternalDataToolDefaultConfig('custom-tool', [provider])).toEqual({
+ region: 'default-region',
+ })
+ expect(getExternalDataToolDefaultConfig('missing-provider', [provider])).toBeUndefined()
+ })
+
+ it('should upsert and remove external data tools by index', () => {
+ const first = createTool({ label: 'First' })
+ const second = createTool({ label: 'Second', variable: 'second_var' })
+
+ expect(upsertExternalDataTool([first], second, -1)).toEqual([first, second])
+ expect(upsertExternalDataTool([first, second], createTool({ label: 'Updated second', variable: 'second_var' }), 1)).toEqual([
+ first,
+ createTool({ label: 'Updated second', variable: 'second_var' }),
+ ])
+ expect(removeExternalDataTool([first, second], 0)).toEqual([second])
+ })
+
+ it('should detect conflicts with prompt variables and other external tools', () => {
+ const existing = [
+ createTool(),
+ createTool({ label: 'Second', variable: 'second_var' }),
+ ]
+
+ expect(findExternalDataToolVariableConflict(undefined, existing, [], -1)).toBeUndefined()
+ expect(findExternalDataToolVariableConflict('prompt_var', existing, [{ key: 'prompt_var' }], -1)).toBe('prompt_var')
+ expect(findExternalDataToolVariableConflict('second_var', existing, [], -1)).toBe('second_var')
+ expect(findExternalDataToolVariableConflict('second_var', existing, [], 1)).toBeUndefined()
+ })
+
+ it('should format api and custom tools for save', () => {
+ const apiTool = createTool({
+ config: {
+ api_based_extension_id: 'extension-1',
+ region: 'ignored',
+ },
+ })
+
+ const customTool = createTool({
+ config: {
+ region: 'us',
+ ignored: 'value',
+ },
+ type: 'custom-tool',
+ })
+
+ expect(formatExternalDataToolForSave(apiTool, undefined, true)).toEqual(expect.objectContaining({
+ enabled: true,
+ config: {
+ api_based_extension_id: 'extension-1',
+ },
+ }))
+
+ expect(formatExternalDataToolForSave(customTool, provider, false)).toEqual(expect.objectContaining({
+ enabled: false,
+ config: {
+ region: 'us',
+ },
+ }))
+ })
+
+ it('should return required and invalid validation errors', () => {
+ expect(getExternalDataToolValidationError({
+ localeData: createTool({ type: '' }),
+ currentProvider: undefined,
+ locale: 'en-US',
+ })).toEqual({
+ kind: 'required',
+ label: 'feature.tools.modal.toolType.title',
+ })
+
+ expect(getExternalDataToolValidationError({
+ localeData: createTool({ label: '' }),
+ currentProvider: undefined,
+ locale: 'en-US',
+ })).toEqual({
+ kind: 'required',
+ label: 'feature.tools.modal.name.title',
+ })
+
+ expect(getExternalDataToolValidationError({
+ localeData: createTool({ variable: '' }),
+ currentProvider: undefined,
+ locale: 'en-US',
+ })).toEqual({
+ kind: 'required',
+ label: 'feature.tools.modal.variableName.title',
+ })
+
+ expect(getExternalDataToolValidationError({
+ localeData: createTool({ variable: 'invalid-key!' }),
+ currentProvider: undefined,
+ locale: 'en-US',
+ })).toEqual({
+ kind: 'invalid',
+ label: 'feature.tools.modal.variableName.title',
+ })
+ })
+
+ it('should validate required provider fields using the current locale', () => {
+ expect(getExternalDataToolValidationError({
+ localeData: createTool({
+ config: {},
+ }),
+ currentProvider: undefined,
+ locale: LanguagesSupported[1],
+ })).toEqual({
+ kind: 'required',
+ label: 'API ๆฉๅฑ',
+ })
+
+ expect(getExternalDataToolValidationError({
+ localeData: createTool({
+ config: {},
+ type: 'custom-tool',
+ }),
+ currentProvider: provider,
+ locale: LanguagesSupported[1],
+ })).toEqual({
+ kind: 'required',
+ label: 'ๅฐๅบ',
+ })
+
+ expect(getExternalDataToolValidationError({
+ localeData: createTool({
+ config: {
+ region: 'us',
+ },
+ type: 'custom-tool',
+ }),
+ currentProvider: provider,
+ locale: 'en-US',
+ })).toBeNull()
+ })
+})
diff --git a/web/app/components/app/configuration/tools/__tests__/index.spec.tsx b/web/app/components/app/configuration/tools/__tests__/index.spec.tsx
new file mode 100644
index 0000000000..144c891fa1
--- /dev/null
+++ b/web/app/components/app/configuration/tools/__tests__/index.spec.tsx
@@ -0,0 +1,173 @@
+import type { ExternalDataTool } from '@/models/common'
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { toast } from '@/app/components/base/ui/toast'
+import Tools from '../index'
+
+const mockCopy = vi.fn()
+const mockSetExternalDataToolsConfig = vi.fn()
+const mockSetShowExternalDataToolModal = vi.fn()
+
+let mockConfigContext: {
+ externalDataToolsConfig: ExternalDataTool[]
+ modelConfig: {
+ configs?: {
+ prompt_variables?: Array<{ key: string }>
+ }
+ }
+ setExternalDataToolsConfig: typeof mockSetExternalDataToolsConfig
+}
+
+vi.mock('copy-to-clipboard', () => ({
+ default: (...args: unknown[]) => mockCopy(...args),
+}))
+
+vi.mock('use-context-selector', async () => {
+ const actual = await vi.importActual('use-context-selector')
+ return {
+ ...actual,
+ useContext: () => mockConfigContext,
+ }
+})
+
+vi.mock('@/context/modal-context', () => ({
+ useModalContext: () => ({
+ setShowExternalDataToolModal: mockSetShowExternalDataToolModal,
+ }),
+}))
+
+vi.mock('@/app/components/base/app-icon', () => ({
+ default: ({ icon }: { icon?: string }) => {icon || 'icon'}
,
+}))
+
+vi.mock('@/app/components/base/switch', () => ({
+ default: ({
+ value,
+ onChange,
+ }: {
+ value: boolean
+ onChange: (value: boolean) => void
+ }) => (
+
+ ),
+}))
+
+vi.mock('@/app/components/base/ui/toast', () => ({
+ toast: {
+ error: vi.fn(),
+ },
+}))
+
+vi.mock('@/app/components/base/ui/tooltip', () => ({
+ Tooltip: ({ children }: { children: React.ReactNode }) => {children}
,
+ TooltipContent: ({ children }: { children: React.ReactNode }) => {children}
,
+ TooltipTrigger: ({
+ render,
+ }: {
+ render: React.ReactNode
+ }) => <>{render}>,
+}))
+
+vi.mock('@remixicon/react', () => ({
+ RiAddLine: () => add-icon,
+ RiArrowDownSLine: () => arrow-icon,
+ RiDeleteBinLine: () => delete-icon,
+}))
+
+vi.mock('@/app/components/base/icons/src/vender/line/general', () => ({
+ Settings01: () => settings-icon,
+}))
+
+vi.mock('@/app/components/base/icons/src/vender/solid/general', () => ({
+ Tool03: () => tool-icon,
+}))
+
+const createTool = (overrides: Partial = {}): ExternalDataTool => ({
+ config: {
+ api_based_extension_id: 'extension-1',
+ },
+ enabled: false,
+ icon: '๐ค',
+ icon_background: '#fff',
+ label: 'External tool',
+ type: 'api',
+ variable: 'tool_var',
+ ...overrides,
+})
+
+describe('configuration/tools/index', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockConfigContext = {
+ externalDataToolsConfig: [],
+ modelConfig: {
+ configs: {
+ prompt_variables: [],
+ },
+ },
+ setExternalDataToolsConfig: mockSetExternalDataToolsConfig,
+ }
+ })
+
+ it('should open the add-tool modal and reject prompt-variable conflicts before save', async () => {
+ const user = userEvent.setup()
+ mockConfigContext.modelConfig.configs!.prompt_variables = [{ key: 'prompt_var' }]
+
+ render()
+
+ await user.click(screen.getByText('common.operation.add'))
+
+ const modalPayload = mockSetShowExternalDataToolModal.mock.calls[0][0]
+
+ expect(modalPayload.payload).toEqual({})
+ expect(modalPayload.onValidateBeforeSaveCallback(createTool({ variable: 'prompt_var' }))).toBe(false)
+ expect(toast.error).toHaveBeenCalledWith(expect.stringContaining('prompt_var'))
+ })
+
+ it('should save, copy, edit, delete, and toggle configured tools', async () => {
+ const user = userEvent.setup()
+ const existingTool = createTool()
+ mockConfigContext.externalDataToolsConfig = [existingTool]
+
+ const { container } = render()
+
+ await user.click(screen.getByText('common.operation.add'))
+
+ const modalPayload = mockSetShowExternalDataToolModal.mock.calls[0][0]
+ modalPayload.onSaveCallback(createTool({ label: 'New tool', variable: 'new_var' }))
+
+ expect(mockSetExternalDataToolsConfig).toHaveBeenCalledWith([
+ existingTool,
+ createTool({ label: 'New tool', variable: 'new_var' }),
+ ])
+
+ await user.click(screen.getByText('tool_var'))
+
+ expect(mockCopy).toHaveBeenCalledWith('tool_var')
+ expect(screen.getByText('appApi.copied')).toBeInTheDocument()
+
+ await user.click(screen.getByText('settings-icon'))
+
+ const editPayload = mockSetShowExternalDataToolModal.mock.calls[1][0]
+ expect(editPayload.payload).toEqual(existingTool)
+ expect(editPayload.onValidateBeforeSaveCallback(createTool({ variable: 'new_var' }))).toBe(true)
+
+ await user.click(screen.getByText('delete-icon'))
+
+ expect(mockSetExternalDataToolsConfig).toHaveBeenCalledWith([])
+
+ await user.click(screen.getByText('switch-off'))
+
+ expect(mockSetExternalDataToolsConfig).toHaveBeenCalledWith([
+ createTool({ enabled: true }),
+ ])
+
+ await user.click(container.querySelector('.group') as HTMLElement)
+
+ expect(screen.queryByText('External tool')).not.toBeInTheDocument()
+ expect(screen.getByText(/appDebug\.feature\.tools\.toolsInUse/)).toBeInTheDocument()
+ })
+})
diff --git a/web/app/components/app/text-generate/item/__tests__/action-bar.spec.tsx b/web/app/components/app/text-generate/item/__tests__/action-bar.spec.tsx
new file mode 100644
index 0000000000..e93687dcdf
--- /dev/null
+++ b/web/app/components/app/text-generate/item/__tests__/action-bar.spec.tsx
@@ -0,0 +1,181 @@
+import type { FeedbackType } from '@/app/components/base/chat/chat/type'
+import type { WorkflowProcess } from '@/app/components/base/chat/types'
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { describe, expect, it, vi } from 'vitest'
+import { WorkflowRunningStatus } from '@/app/components/workflow/types'
+import { AppSourceType } from '@/service/share'
+import GenerationItemActionBar from '../action-bar'
+
+vi.mock('@/app/components/base/action-button', () => ({
+ default: ({
+ children,
+ disabled,
+ onClick,
+ state,
+ }: {
+ children: React.ReactNode
+ disabled?: boolean
+ onClick?: () => void
+ state?: string
+ }) => (
+
+ ),
+ ActionButtonState: {
+ Active: 'active',
+ Default: 'default',
+ Destructive: 'destructive',
+ Disabled: 'disabled',
+ },
+}))
+
+vi.mock('@/app/components/base/new-audio-button', () => ({
+ default: ({ id, voice }: { id: string, voice?: string }) => {`audio:${id}:${voice || ''}`}
,
+}))
+
+vi.mock('@remixicon/react', () => ({
+ RiBookmark3Line: () => bookmark-icon,
+ RiClipboardLine: () => copy-icon,
+ RiFileList3Line: () => log-icon,
+ RiResetLeftLine: () => retry-icon,
+ RiSparklingLine: () => more-like-this-icon,
+ RiThumbDownLine: () => thumb-down-icon,
+ RiThumbUpLine: () => thumb-up-icon,
+}))
+
+const createWorkflowProcessData = (overrides: Partial = {}): WorkflowProcess => ({
+ status: WorkflowRunningStatus.Succeeded,
+ tracing: [],
+ ...overrides,
+})
+
+const createProps = (overrides: Partial> = {}): React.ComponentProps => ({
+ appSourceType: AppSourceType.webApp,
+ currentTab: 'RESULT',
+ depth: 1,
+ feedback: { rating: null } as FeedbackType,
+ isError: false,
+ isInWebApp: true,
+ isResponding: false,
+ isShowTextToSpeech: true,
+ isTryApp: false,
+ isWorkflow: false,
+ messageId: 'message-1',
+ moreLikeThis: true,
+ onCopy: vi.fn(),
+ onFeedback: vi.fn(),
+ onMoreLikeThis: vi.fn(),
+ onOpenLogModal: vi.fn(),
+ onRetry: vi.fn(),
+ onSave: vi.fn(),
+ supportFeedback: true,
+ voice: 'alloy',
+ workflowProcessData: createWorkflowProcessData({ resultText: 'done' }),
+ ...overrides,
+})
+
+describe('GenerationItemActionBar', () => {
+ it('should render non-web log actions and invoke the corresponding handlers', async () => {
+ const user = userEvent.setup()
+ const onOpenLogModal = vi.fn()
+ const onCopy = vi.fn()
+ const onMoreLikeThis = vi.fn()
+
+ render(
+ ,
+ )
+
+ await user.click(screen.getByText('log-icon'))
+ await user.click(screen.getByText('more-like-this-icon'))
+ await user.click(screen.getByText('copy-icon'))
+
+ expect(onOpenLogModal).toHaveBeenCalledTimes(1)
+ expect(onMoreLikeThis).toHaveBeenCalledTimes(1)
+ expect(onCopy).toHaveBeenCalledTimes(1)
+ expect(screen.getByText('audio:message-1:alloy')).toBeInTheDocument()
+ })
+
+ it('should render retry, save, and feedback controls for web apps', async () => {
+ const user = userEvent.setup()
+ const onRetry = vi.fn()
+ const onSave = vi.fn()
+ const onFeedback = vi.fn()
+
+ render(
+ ,
+ )
+
+ await user.click(screen.getByText('retry-icon'))
+ await user.click(screen.getByText('bookmark-icon'))
+
+ expect(onRetry).toHaveBeenCalledTimes(1)
+ expect(onSave).not.toHaveBeenCalled()
+
+ render()
+
+ await user.click(screen.getAllByText('bookmark-icon').at(-1)!)
+
+ expect(onSave).toHaveBeenCalledWith('message-1')
+
+ render()
+
+ await user.click(screen.getAllByText('thumb-up-icon').at(-1)!)
+ await user.click(screen.getAllByText('thumb-down-icon').at(-1)!)
+
+ expect(onFeedback).toHaveBeenCalledWith({ rating: 'like' })
+ expect(onFeedback).toHaveBeenCalledWith({ rating: 'dislike' })
+ })
+
+ it('should disable more-like-this at the maximum depth and render active feedback state toggles', async () => {
+ const user = userEvent.setup()
+ const onFeedback = vi.fn()
+
+ render(
+ ,
+ )
+
+ expect(screen.getByText('more-like-this-icon').closest('button')).toBeDisabled()
+
+ await user.click(screen.getByText('thumb-up-icon'))
+
+ expect(onFeedback).toHaveBeenCalledWith({ rating: null })
+
+ render(
+ ,
+ )
+
+ await user.click(screen.getAllByText('thumb-down-icon')[0])
+
+ expect(onFeedback).toHaveBeenCalledWith({ rating: null })
+ expect(screen.getByText('appDebug.errorMessage.waitForResponse')).toBeInTheDocument()
+ })
+})
diff --git a/web/app/components/app/text-generate/item/__tests__/index.spec.tsx b/web/app/components/app/text-generate/item/__tests__/index.spec.tsx
new file mode 100644
index 0000000000..8f555b689e
--- /dev/null
+++ b/web/app/components/app/text-generate/item/__tests__/index.spec.tsx
@@ -0,0 +1,162 @@
+import type { FeedbackType } from '@/app/components/base/chat/chat/type'
+import type { WorkflowProcess } from '@/app/components/base/chat/types'
+import { render, screen } from '@testing-library/react'
+import { describe, expect, it, vi } from 'vitest'
+import { WorkflowRunningStatus } from '@/app/components/workflow/types'
+import { AppSourceType } from '@/service/share'
+import GenerationItem from '../index'
+
+const mockActionBar = vi.fn()
+const mockUseGenerationItem = vi.fn()
+
+vi.mock('@/app/components/base/loading', () => ({
+ default: ({ type }: { type?: string }) => {`loading:${type || 'default'}`}
,
+}))
+
+vi.mock('@/app/components/base/markdown', () => ({
+ Markdown: ({ content }: { content: string }) => {`markdown:${content}`}
,
+}))
+
+vi.mock('../action-bar', () => ({
+ default: (props: Record) => {
+ mockActionBar(props)
+ return {`action-bar:${String(props.messageId)}`}
+ },
+}))
+
+vi.mock('../workflow-content', () => ({
+ default: ({
+ currentTab,
+ taskId,
+ }: {
+ currentTab: string
+ taskId?: string
+ }) => {`workflow-content:${currentTab}:${taskId || ''}`}
,
+}))
+
+vi.mock('../use-generation-item', () => ({
+ useGenerationItem: (...args: unknown[]) => mockUseGenerationItem(...args),
+}))
+
+const createWorkflowProcessData = (overrides: Partial = {}): WorkflowProcess => ({
+ status: WorkflowRunningStatus.Succeeded,
+ tracing: [],
+ ...overrides,
+})
+
+const createHookState = (overrides: Record = {}) => ({
+ childMessageId: null,
+ childProps: {
+ appSourceType: AppSourceType.webApp,
+ content: 'child content',
+ depth: 2,
+ isError: false,
+ onRetry: vi.fn(),
+ siteInfo: null,
+ },
+ config: {
+ text_to_speech: {
+ voice: 'alloy',
+ },
+ },
+ completionRes: '',
+ currentTab: 'RESULT',
+ handleCopy: vi.fn(),
+ handleMoreLikeThis: vi.fn(),
+ handleOpenLogModal: vi.fn(),
+ handleSubmitHumanInputForm: vi.fn(),
+ isQuerying: false,
+ isTop: true,
+ isTryApp: false,
+ setCurrentTab: vi.fn(),
+ showChildItem: false,
+ taskLabel: 'task-1',
+ ...overrides,
+})
+
+const createProps = (overrides: Partial> = {}): React.ComponentProps => ({
+ appSourceType: AppSourceType.webApp,
+ content: 'Hello world',
+ feedback: { rating: null } as FeedbackType,
+ isError: false,
+ isLoading: false,
+ isMobile: false,
+ isWorkflow: false,
+ messageId: 'message-1',
+ onFeedback: vi.fn(),
+ onRetry: vi.fn(),
+ onSave: vi.fn(),
+ siteInfo: null,
+ taskId: 'task-1',
+ ...overrides,
+})
+
+describe('GenerationItem', () => {
+ it('should render the loading state while waiting for a response', () => {
+ mockUseGenerationItem.mockReturnValue(createHookState())
+
+ render()
+
+ expect(screen.getByText('loading:area')).toBeInTheDocument()
+ expect(screen.queryByText(/action-bar:/)).not.toBeInTheDocument()
+ })
+
+ it('should render workflow content and pass the derived action-bar props', () => {
+ mockUseGenerationItem.mockReturnValue(createHookState({
+ currentTab: 'DETAIL',
+ }))
+
+ render(
+ ,
+ )
+
+ expect(screen.getByText('workflow-content:DETAIL:task-9')).toBeInTheDocument()
+ expect(screen.queryByText(/common\.unit\.char/)).not.toBeInTheDocument()
+ expect(mockActionBar.mock.calls[0][0]).toEqual(expect.objectContaining({
+ currentTab: 'DETAIL',
+ isWorkflow: true,
+ messageId: 'message-1',
+ voice: 'alloy',
+ }))
+ })
+
+ it('should render markdown output, task header, char count, and nested child items', () => {
+ mockUseGenerationItem
+ .mockReturnValueOnce(createHookState({
+ childProps: createProps({
+ content: 'Child answer',
+ depth: 2,
+ messageId: 'child-1',
+ }),
+ showChildItem: true,
+ }))
+ .mockReturnValueOnce(createHookState({
+ childProps: createProps({
+ content: '',
+ depth: 3,
+ messageId: 'child-2',
+ }),
+ isTop: false,
+ showChildItem: false,
+ taskLabel: 'task-1-1',
+ }))
+
+ render()
+
+ expect(screen.getAllByText('share.generation.execution')).toHaveLength(2)
+ expect(screen.getByText('task-1')).toBeInTheDocument()
+ expect(screen.getByText('markdown:Hello world')).toBeInTheDocument()
+ expect(screen.getByText(/11\s+common\.unit\.char/)).toBeInTheDocument()
+ expect(screen.getByText('markdown:Child answer')).toBeInTheDocument()
+ expect(screen.getAllByText(/action-bar:/)).toHaveLength(2)
+ })
+})
diff --git a/web/app/components/app/text-generate/item/__tests__/result-tab.spec.tsx b/web/app/components/app/text-generate/item/__tests__/result-tab.spec.tsx
new file mode 100644
index 0000000000..d95eb0109d
--- /dev/null
+++ b/web/app/components/app/text-generate/item/__tests__/result-tab.spec.tsx
@@ -0,0 +1,51 @@
+import type { WorkflowProcess } from '@/app/components/base/chat/types'
+import { render, screen } from '@testing-library/react'
+import { describe, expect, it, vi } from 'vitest'
+import ResultTab from '../result-tab'
+
+vi.mock('@/app/components/base/file-uploader', () => ({
+ FileList: ({ files }: { files: Array<{ id: string }> }) => {`files:${files.length}`}
,
+}))
+
+vi.mock('@/app/components/base/markdown', () => ({
+ Markdown: ({ content }: { content: string }) => {`markdown:${content}`}
,
+}))
+
+vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({
+ default: ({ value }: { value: unknown }) => {`code-editor:${String(value)}`}
,
+}))
+
+describe('ResultTab', () => {
+ it('should render markdown text and uploaded files in result mode', () => {
+ const workflowProcessData = {
+ files: [{
+ list: [{ id: 'file-1' }],
+ varName: 'documents',
+ }],
+ resultText: 'Generated result',
+ } as unknown as WorkflowProcess
+
+ render(
+ ,
+ )
+
+ expect(screen.getByText('markdown:Generated result')).toBeInTheDocument()
+ expect(screen.getByText('documents')).toBeInTheDocument()
+ expect(screen.getByText('files:1')).toBeInTheDocument()
+ })
+
+ it('should render the JSON detail view in detail mode', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByText('code-editor:{"raw":true}')).toBeInTheDocument()
+ })
+})
diff --git a/web/app/components/app/text-generate/item/__tests__/use-generation-item.spec.tsx b/web/app/components/app/text-generate/item/__tests__/use-generation-item.spec.tsx
new file mode 100644
index 0000000000..112f772302
--- /dev/null
+++ b/web/app/components/app/text-generate/item/__tests__/use-generation-item.spec.tsx
@@ -0,0 +1,339 @@
+import type { IChatItem } from '@/app/components/base/chat/chat/type'
+import type { WorkflowProcess } from '@/app/components/base/chat/types'
+import type { FileEntity } from '@/app/components/base/file-uploader/types'
+import type { SiteInfo } from '@/models/share'
+import { act, renderHook, waitFor } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { toast } from '@/app/components/base/ui/toast'
+import { WorkflowRunningStatus } from '@/app/components/workflow/types'
+import { AppSourceType } from '@/service/share'
+import { TransferMethod } from '@/types/app'
+import { generationItemHelpers, useGenerationItem } from '../use-generation-item'
+
+const mockCopy = vi.fn()
+const mockFetchTextGenerationMessage = vi.fn()
+const mockFetchMoreLikeThis = vi.fn()
+const mockSubmitHumanInputForm = vi.fn()
+const mockUpdateFeedback = vi.fn()
+const mockSubmitHumanInputFormService = vi.fn()
+const mockSetCurrentLogItem = vi.fn()
+const mockSetShowPromptLogModal = vi.fn()
+
+vi.mock('copy-to-clipboard', () => ({
+ default: (...args: unknown[]) => mockCopy(...args),
+}))
+
+vi.mock('@/app/components/app/store', () => ({
+ useStore: (selector: (state: {
+ setCurrentLogItem: typeof mockSetCurrentLogItem
+ setShowPromptLogModal: typeof mockSetShowPromptLogModal
+ }) => unknown) => selector({
+ setCurrentLogItem: mockSetCurrentLogItem,
+ setShowPromptLogModal: mockSetShowPromptLogModal,
+ }),
+}))
+
+vi.mock('@/app/components/base/chat/chat/context', () => ({
+ useChatContext: () => ({
+ config: {
+ text_to_speech: {
+ voice: 'alloy',
+ },
+ },
+ }),
+}))
+
+vi.mock('@/app/components/base/ui/toast', () => ({
+ toast: {
+ success: vi.fn(),
+ warning: vi.fn(),
+ },
+}))
+
+vi.mock('@/next/navigation', () => ({
+ useParams: () => ({
+ appId: 'app-1',
+ }),
+}))
+
+vi.mock('@/service/debug', () => ({
+ fetchTextGenerationMessage: (...args: unknown[]) => mockFetchTextGenerationMessage(...args),
+}))
+
+vi.mock('@/service/share', async () => {
+ const actual = await vi.importActual('@/service/share')
+ return {
+ ...actual,
+ fetchMoreLikeThis: (...args: unknown[]) => mockFetchMoreLikeThis(...args),
+ submitHumanInputForm: (...args: unknown[]) => mockSubmitHumanInputForm(...args),
+ updateFeedback: (...args: unknown[]) => mockUpdateFeedback(...args),
+ }
+})
+
+vi.mock('@/service/workflow', async () => {
+ const actual = await vi.importActual('@/service/workflow')
+ return {
+ ...actual,
+ submitHumanInputForm: (...args: unknown[]) => mockSubmitHumanInputFormService(...args),
+ }
+})
+
+const createSiteInfo = (overrides: Partial = {}): SiteInfo => ({
+ title: 'App site',
+ show_workflow_steps: true,
+ ...overrides,
+})
+
+const createWorkflowProcessData = (overrides: Partial = {}): WorkflowProcess => ({
+ status: WorkflowRunningStatus.Succeeded,
+ tracing: [],
+ ...overrides,
+})
+
+const createMessageFile = (overrides: Partial = {}): FileEntity => ({
+ id: 'file-1',
+ name: 'file.txt',
+ progress: 100,
+ size: 1,
+ supportFileType: 'document',
+ transferMethod: TransferMethod.local_file,
+ type: 'text/plain',
+ ...overrides,
+})
+
+const createProps = (overrides: Partial[0]> = {}): Parameters[0] => ({
+ appSourceType: AppSourceType.webApp,
+ content: 'Initial content',
+ controlClearMoreLikeThis: 0,
+ depth: 1,
+ installedAppId: 'installed-1',
+ isInWebApp: true,
+ isLoading: false,
+ isMobile: false,
+ isShowTextToSpeech: true,
+ isWorkflow: false,
+ messageId: 'message-1',
+ onRetry: vi.fn(),
+ onSave: vi.fn(),
+ siteInfo: createSiteInfo(),
+ taskId: 'task-1',
+ workflowProcessData: undefined,
+ ...overrides,
+})
+
+describe('useGenerationItem', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockFetchMoreLikeThis.mockResolvedValue({
+ answer: 'Suggested answer',
+ id: 'child-1',
+ })
+ mockFetchTextGenerationMessage.mockResolvedValue({
+ answer: 'Assistant answer',
+ id: 'message-1',
+ message: 'Original prompt',
+ message_files: [createMessageFile()],
+ })
+ })
+
+ it('should derive the current tab and helper flags from workflow data', async () => {
+ const { result, rerender } = renderHook(props => useGenerationItem(props), {
+ initialProps: createProps({
+ appSourceType: AppSourceType.tryApp,
+ depth: 2,
+ isWorkflow: true,
+ workflowProcessData: createWorkflowProcessData({
+ resultText: 'Workflow result',
+ }),
+ }),
+ })
+
+ expect(result.current.currentTab).toBe('RESULT')
+ expect(result.current.isTop).toBe(false)
+ expect(result.current.isTryApp).toBe(true)
+ expect(result.current.taskLabel).toBe('task-1-1')
+ expect(result.current.config?.text_to_speech?.voice).toBe('alloy')
+
+ rerender(createProps({
+ depth: 2,
+ isWorkflow: true,
+ workflowProcessData: createWorkflowProcessData(),
+ }))
+
+ await waitFor(() => {
+ expect(result.current.currentTab).toBe('DETAIL')
+ })
+ })
+
+ it('should request more-like-this responses and reuse the returned message for child feedback', async () => {
+ const { result } = renderHook(() => useGenerationItem(createProps()))
+
+ await act(async () => {
+ await result.current.handleMoreLikeThis()
+ })
+
+ expect(mockFetchMoreLikeThis).toHaveBeenCalledWith('message-1', AppSourceType.webApp, 'installed-1')
+ expect(result.current.completionRes).toBe('Suggested answer')
+ expect(result.current.childMessageId).toBe('child-1')
+ expect(result.current.showChildItem).toBe(true)
+ expect(result.current.childProps.messageId).toBe('child-1')
+ expect(result.current.childProps.feedback).toEqual({ rating: null })
+
+ await act(async () => {
+ await result.current.childProps.onFeedback?.({ rating: 'like' })
+ })
+
+ expect(mockUpdateFeedback).toHaveBeenCalledWith({
+ body: { rating: 'like' },
+ url: '/messages/child-1/feedbacks',
+ }, AppSourceType.webApp, 'installed-1')
+ })
+
+ it('should warn instead of requesting more-like-this when no source message is available', async () => {
+ const { result } = renderHook(() => useGenerationItem(createProps({ messageId: undefined })))
+
+ await act(async () => {
+ await result.current.handleMoreLikeThis()
+ })
+
+ expect(mockFetchMoreLikeThis).not.toHaveBeenCalled()
+ expect(toast.warning).toHaveBeenCalledWith('appDebug.errorMessage.waitForResponse')
+ })
+
+ it('should clear child content when the clear control changes or the parent starts loading', async () => {
+ const { result, rerender } = renderHook(props => useGenerationItem(props), {
+ initialProps: createProps(),
+ })
+
+ await act(async () => {
+ await result.current.handleMoreLikeThis()
+ })
+
+ expect(result.current.childMessageId).toBe('child-1')
+
+ rerender(createProps({ controlClearMoreLikeThis: 1 }))
+
+ await waitFor(() => {
+ expect(result.current.childMessageId).toBeNull()
+ expect(result.current.completionRes).toBe('')
+ })
+
+ await act(async () => {
+ await result.current.handleMoreLikeThis()
+ })
+
+ rerender(createProps({ isLoading: true }))
+
+ await waitFor(() => {
+ expect(result.current.childMessageId).toBeNull()
+ })
+ })
+
+ it('should normalize log entries and open the prompt log modal', async () => {
+ const { result } = renderHook(() => useGenerationItem(createProps()))
+
+ await act(async () => {
+ await result.current.handleOpenLogModal()
+ })
+
+ expect(mockFetchTextGenerationMessage).toHaveBeenCalledWith({
+ appId: 'app-1',
+ messageId: 'message-1',
+ })
+ expect(mockSetShowPromptLogModal).toHaveBeenCalledWith(true)
+ expect(mockSetCurrentLogItem).toHaveBeenCalledWith(expect.objectContaining({
+ content: 'Assistant answer',
+ id: 'message-1',
+ isAnswer: true,
+ log: [{
+ role: 'user',
+ text: 'Original prompt',
+ }],
+ }))
+ })
+
+ it('should route human input submissions to share and workflow services based on app source', async () => {
+ const shareHook = renderHook(() => useGenerationItem(createProps()))
+
+ await act(async () => {
+ await shareHook.result.current.handleSubmitHumanInputForm('token-1', {
+ action: 'submit',
+ inputs: { city: 'Paris' },
+ })
+ })
+
+ expect(mockSubmitHumanInputForm).toHaveBeenCalledWith('token-1', {
+ action: 'submit',
+ inputs: { city: 'Paris' },
+ })
+
+ const installedHook = renderHook(() => useGenerationItem(createProps({
+ appSourceType: AppSourceType.installedApp,
+ })))
+
+ await act(async () => {
+ await installedHook.result.current.handleSubmitHumanInputForm('token-2', {
+ action: 'confirm',
+ inputs: { city: 'Berlin' },
+ })
+ })
+
+ expect(mockSubmitHumanInputFormService).toHaveBeenCalledWith('token-2', {
+ action: 'confirm',
+ inputs: { city: 'Berlin' },
+ })
+ })
+
+ it('should copy workflow text directly and stringify non-string content', async () => {
+ const { result: workflowResult } = renderHook(() => useGenerationItem(createProps({
+ content: { raw: true },
+ isWorkflow: true,
+ workflowProcessData: createWorkflowProcessData({
+ resultText: 'Workflow text',
+ }),
+ })))
+
+ await act(async () => {
+ workflowResult.current.handleCopy()
+ })
+
+ expect(mockCopy).toHaveBeenCalledWith('Workflow text')
+ expect(toast.success).toHaveBeenCalledWith('common.actionMsg.copySuccessfully')
+
+ const { result: jsonResult } = renderHook(() => useGenerationItem(createProps({
+ content: { raw: true },
+ })))
+
+ await act(async () => {
+ jsonResult.current.handleCopy()
+ })
+
+ expect(mockCopy).toHaveBeenCalledWith(JSON.stringify({ raw: true }))
+ })
+})
+
+describe('generationItemHelpers', () => {
+ it('should build a normalized log item from text messages', () => {
+ const logItem = generationItemHelpers.buildLogItem({
+ answer: 'Assistant answer',
+ data: {
+ answer: 'Assistant answer',
+ id: 'message-1',
+ message: 'Original prompt',
+ message_files: [createMessageFile()],
+ },
+ messageId: 'message-1',
+ }) as IChatItem
+
+ expect(logItem.id).toBe('message-1')
+ expect(logItem.log).toEqual([{ role: 'user', text: 'Original prompt' }])
+ })
+
+ it('should compute the default tab from workflow result availability', () => {
+ expect(generationItemHelpers.getCurrentTab(createWorkflowProcessData({ resultText: 'done' }))).toBe('RESULT')
+ expect(generationItemHelpers.getCurrentTab(createWorkflowProcessData({
+ files: [{ id: 'file-1' }] as unknown as WorkflowProcess['files'],
+ }))).toBe('RESULT')
+ expect(generationItemHelpers.getCurrentTab(createWorkflowProcessData())).toBe('DETAIL')
+ })
+})
diff --git a/web/app/components/app/text-generate/item/__tests__/workflow-content.spec.tsx b/web/app/components/app/text-generate/item/__tests__/workflow-content.spec.tsx
new file mode 100644
index 0000000000..7f74267a41
--- /dev/null
+++ b/web/app/components/app/text-generate/item/__tests__/workflow-content.spec.tsx
@@ -0,0 +1,142 @@
+import type { WorkflowProcess } from '@/app/components/base/chat/types'
+import type { SiteInfo } from '@/models/share'
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { describe, expect, it, vi } from 'vitest'
+import { WorkflowRunningStatus } from '@/app/components/workflow/types'
+import WorkflowContent from '../workflow-content'
+
+vi.mock('@/app/components/base/chat/chat/answer/human-input-filled-form-list', () => ({
+ default: ({ humanInputFilledFormDataList }: { humanInputFilledFormDataList: Array }) => (
+ {`filled-forms:${humanInputFilledFormDataList.length}`}
+ ),
+}))
+
+vi.mock('@/app/components/base/chat/chat/answer/human-input-form-list', () => ({
+ default: ({
+ humanInputFormDataList,
+ onHumanInputFormSubmit,
+ }: {
+ humanInputFormDataList: Array
+ onHumanInputFormSubmit: (formToken: string, formData: { inputs: Record, action: string }) => void
+ }) => (
+
+ ),
+}))
+
+vi.mock('@/app/components/base/chat/chat/answer/workflow-process', () => ({
+ default: ({
+ readonly,
+ }: {
+ readonly: boolean
+ }) => {`workflow-process:${String(readonly)}`}
,
+}))
+
+vi.mock('../result-tab', () => ({
+ default: ({
+ currentTab,
+ content,
+ }: {
+ currentTab: string
+ content: unknown
+ }) => {`result-tab:${currentTab}:${String(content)}`}
,
+}))
+
+const createWorkflowProcessData = () => ({
+ status: WorkflowRunningStatus.Succeeded,
+ tracing: [],
+ expand: true,
+ files: [{ list: [{ id: 'file-1' }], varName: 'documents' }],
+ humanInputFilledFormDataList: [{ id: 'filled-1' }],
+ humanInputFormDataList: [{ id: 'form-1' }],
+ resultText: 'Workflow result',
+}) as unknown as WorkflowProcess
+
+const workflowSiteInfo: SiteInfo = {
+ title: 'App site',
+ show_workflow_steps: false,
+}
+
+describe('WorkflowContent', () => {
+ it('should render workflow metadata, result tabs, and forward human input submissions', async () => {
+ const user = userEvent.setup()
+ const onSubmitHumanInputForm = vi.fn()
+ const onSwitchTab = vi.fn()
+
+ render(
+ ,
+ )
+
+ expect(screen.getByText('share.generation.execution')).toBeInTheDocument()
+ expect(screen.getByText('task-1')).toBeInTheDocument()
+ expect(screen.getByText('workflow-process:true')).toBeInTheDocument()
+ expect(screen.getByText('runLog.result')).toBeInTheDocument()
+ expect(screen.getByText('runLog.detail')).toBeInTheDocument()
+ expect(screen.getByText('human-forms:1')).toBeInTheDocument()
+ expect(screen.getByText('filled-forms:1')).toBeInTheDocument()
+ expect(screen.getByText('result-tab:RESULT:workflow-json')).toBeInTheDocument()
+
+ await user.click(screen.getByText('runLog.detail'))
+ await user.click(screen.getByText('human-forms:1'))
+
+ expect(onSwitchTab).toHaveBeenCalledWith('DETAIL')
+ expect(onSubmitHumanInputForm).toHaveBeenCalledWith('token-1', {
+ action: 'submit',
+ inputs: { city: 'Paris' },
+ })
+ })
+
+ it('should hide result tabs and content helpers when the workflow is in an error state', () => {
+ render(
+ ,
+ )
+
+ expect(screen.queryByText('runLog.result')).not.toBeInTheDocument()
+ expect(screen.queryByText(/result-tab:/)).not.toBeInTheDocument()
+ expect(screen.queryByText(/workflow-process:/)).not.toBeInTheDocument()
+ })
+
+ it('should switch back to the result tab when the detail tab is active', async () => {
+ const user = userEvent.setup()
+ const onSwitchTab = vi.fn()
+
+ render(
+ ,
+ )
+
+ await user.click(screen.getByText('runLog.result'))
+
+ expect(onSwitchTab).toHaveBeenCalledWith('RESULT')
+ })
+})
diff --git a/web/app/components/app/text-generate/item/index.tsx b/web/app/components/app/text-generate/item/index.tsx
index f3396e60ed..b71260af7a 100644
--- a/web/app/components/app/text-generate/item/index.tsx
+++ b/web/app/components/app/text-generate/item/index.tsx
@@ -1,32 +1,18 @@
'use client'
import type { FC } from 'react'
-import type { FeedbackType, IChatItem } from '@/app/components/base/chat/chat/type'
+import type { FeedbackType } from '@/app/components/base/chat/chat/type'
import type { WorkflowProcess } from '@/app/components/base/chat/types'
import type { SiteInfo } from '@/models/share'
-import {
- RiPlayList2Line,
- RiSparklingFill,
-} from '@remixicon/react'
-import { useBoolean } from 'ahooks'
-import copy from 'copy-to-clipboard'
+import type { AppSourceType } from '@/service/share'
import * as React from 'react'
-import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
-import { useStore as useAppStore } from '@/app/components/app/store'
-import { useChatContext } from '@/app/components/base/chat/chat/context'
import Loading from '@/app/components/base/loading'
import { Markdown } from '@/app/components/base/markdown'
-import { toast } from '@/app/components/base/ui/toast'
-import { useParams } from '@/next/navigation'
-import { fetchTextGenerationMessage } from '@/service/debug'
-import { AppSourceType, fetchMoreLikeThis, submitHumanInputForm, updateFeedback } from '@/service/share'
-import { submitHumanInputForm as submitHumanInputFormService } from '@/service/workflow'
import { cn } from '@/utils/classnames'
import GenerationItemActionBar from './action-bar'
+import { useGenerationItem } from './use-generation-item'
import WorkflowContent from './workflow-content'
-const MAX_DEPTH = 3
-
export type IGenerationItemProps = {
isWorkflow?: boolean
workflowProcessData?: WorkflowProcess
@@ -90,142 +76,28 @@ const GenerationItem: FC = ({
inSidePanel,
}) => {
const { t } = useTranslation()
- const params = useParams()
- const isTop = depth === 1
- const isTryApp = appSourceType === AppSourceType.tryApp
- const [completionRes, setCompletionRes] = useState('')
- const [childMessageId, setChildMessageId] = useState(null)
- const [childFeedback, setChildFeedback] = useState({
- rating: null,
- })
- const {
- config,
- } = useChatContext()
-
- const setCurrentLogItem = useAppStore(s => s.setCurrentLogItem)
- const setShowPromptLogModal = useAppStore(s => s.setShowPromptLogModal)
-
- const handleFeedback = async (childFeedback: FeedbackType) => {
- await updateFeedback({ url: `/messages/${childMessageId}/feedbacks`, body: { rating: childFeedback.rating } }, appSourceType, installedAppId)
- setChildFeedback(childFeedback)
- }
-
- const [isQuerying, { setTrue: startQuerying, setFalse: stopQuerying }] = useBoolean(false)
-
- const childProps: IGenerationItemProps = {
- isInWebApp,
- content: completionRes,
- messageId: childMessageId,
- depth: depth + 1,
- moreLikeThis: true,
- onFeedback: handleFeedback,
- isLoading: isQuerying,
- feedback: childFeedback,
- onSave,
- isShowTextToSpeech,
- isMobile,
+ const state = useGenerationItem({
appSourceType,
- installedAppId,
+ content,
controlClearMoreLikeThis,
+ depth,
+ installedAppId,
+ isInWebApp,
+ isLoading,
+ isMobile,
+ isShowTextToSpeech,
isWorkflow,
+ messageId,
+ onRetry,
+ onSave,
siteInfo,
taskId,
- isError: false,
- onRetry,
- }
-
- const handleMoreLikeThis = async () => {
- if (isQuerying || !messageId) {
- toast.warning(t('errorMessage.waitForResponse', { ns: 'appDebug' }))
- return
- }
- startQuerying()
- const res: any = await fetchMoreLikeThis(messageId as string, appSourceType, installedAppId)
- setCompletionRes(res.answer)
- setChildFeedback({
- rating: null,
- })
- setChildMessageId(res.id)
- stopQuerying()
- }
-
- useEffect(() => {
- if (controlClearMoreLikeThis) {
- setChildMessageId(null)
- setCompletionRes('')
- }
- }, [controlClearMoreLikeThis])
-
- // regeneration clear child
- useEffect(() => {
- if (isLoading)
- setChildMessageId(null)
- }, [isLoading])
-
- const handleOpenLogModal = async () => {
- const data = await fetchTextGenerationMessage({
- appId: params.appId as string,
- messageId: messageId!,
- })
- const assistantFiles = data.message_files?.filter(file => file.belongs_to === 'assistant') || []
- const normalizedMessage = typeof data.message === 'string'
- ? { role: 'user', text: data.message }
- : data.message
- const baseLog = Array.isArray(normalizedMessage) ? normalizedMessage : [normalizedMessage]
- const log = Array.isArray(normalizedMessage)
- ? [
- ...normalizedMessage,
- ...(normalizedMessage.length > 0 && normalizedMessage[normalizedMessage.length - 1].role !== 'assistant'
- ? [
- {
- role: 'assistant',
- text: data.answer || '',
- files: assistantFiles,
- },
- ]
- : []),
- ]
- : baseLog
- const logItem: IChatItem = {
- id: data.id || messageId || '',
- content: data.answer || '',
- isAnswer: true,
- log,
- message_files: data.message_files,
- }
- setCurrentLogItem(logItem)
- setShowPromptLogModal(true)
- }
-
- const [currentTab, setCurrentTab] = useState('DETAIL')
- const switchTab = async (tab: string) => {
- setCurrentTab(tab)
- }
- useEffect(() => {
- if (workflowProcessData?.resultText || !!workflowProcessData?.files?.length || (workflowProcessData?.humanInputFormDataList && workflowProcessData?.humanInputFormDataList.length > 0) || (workflowProcessData?.humanInputFilledFormDataList && workflowProcessData?.humanInputFilledFormDataList.length > 0))
- switchTab('RESULT')
- else
- switchTab('DETAIL')
- }, [workflowProcessData?.files?.length, workflowProcessData?.resultText, workflowProcessData?.humanInputFormDataList, workflowProcessData?.humanInputFilledFormDataList])
- const handleSubmitHumanInputForm = useCallback(async (formToken: string, formData: { inputs: Record, action: string }) => {
- if (appSourceType === AppSourceType.installedApp)
- await submitHumanInputFormService(formToken, formData)
- else
- await submitHumanInputForm(formToken, formData)
- }, [appSourceType])
-
- const handleCopy = useCallback(() => {
- const copyContent = isWorkflow ? workflowProcessData?.resultText : content
- if (typeof copyContent === 'string')
- copy(copyContent)
- else
- copy(JSON.stringify(copyContent))
- toast.success(t('actionMsg.copySuccessfully', { ns: 'common' }))
- }, [content, isWorkflow, t, workflowProcessData?.resultText])
+ workflowProcessData,
+ })
return (
<>
-
+
{isLoading && (
)}
@@ -238,26 +110,24 @@ const GenerationItem: FC
= ({
)}
>
{workflowProcessData && (
- <>
-
- >
+
)}
{!workflowProcessData && taskId && (
-
+
{t('generation.execution', { ns: 'share' })}
ยท
- {`${taskId}${depth > 1 ? `-${depth - 1}` : ''}`}
+ {state.taskLabel}
)}
{isError && (
@@ -272,7 +142,7 @@ const GenerationItem: FC = ({
{/* meta data */}
{!isWorkflow && (
@@ -286,31 +156,31 @@ const GenerationItem: FC
= ({
{/* more like this elements */}
- {!isTop && (
+ {!state.isTop && (
= ({
isMobile ? 'top-[3.5px]' : 'top-2',
)}
>
-
+
)}
>
)}
- {((childMessageId || isQuerying) && depth < MAX_DEPTH) && (
-
+ {state.showChildItem && (
+
)}
>
)
diff --git a/web/app/components/app/text-generate/item/use-generation-item.ts b/web/app/components/app/text-generate/item/use-generation-item.ts
new file mode 100644
index 0000000000..eefdd2be23
--- /dev/null
+++ b/web/app/components/app/text-generate/item/use-generation-item.ts
@@ -0,0 +1,308 @@
+'use client'
+
+import type { IGenerationItemProps } from './index'
+import type { FeedbackType, IChatItem } from '@/app/components/base/chat/chat/type'
+import type { WorkflowProcess } from '@/app/components/base/chat/types'
+import { useBoolean } from 'ahooks'
+import copy from 'copy-to-clipboard'
+import { useCallback, useMemo, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import { useStore as useAppStore } from '@/app/components/app/store'
+import { useChatContext } from '@/app/components/base/chat/chat/context'
+import { toast } from '@/app/components/base/ui/toast'
+import { useParams } from '@/next/navigation'
+import { fetchTextGenerationMessage } from '@/service/debug'
+import {
+ AppSourceType,
+ fetchMoreLikeThis,
+ submitHumanInputForm,
+ updateFeedback,
+} from '@/service/share'
+import { submitHumanInputForm as submitHumanInputFormService } from '@/service/workflow'
+
+const MAX_DEPTH = 3
+
+const getCurrentTab = (workflowProcessData?: WorkflowProcess) => {
+ if (
+ workflowProcessData?.resultText
+ || !!workflowProcessData?.files?.length
+ || !!workflowProcessData?.humanInputFormDataList?.length
+ || !!workflowProcessData?.humanInputFilledFormDataList?.length
+ ) {
+ return 'RESULT'
+ }
+
+ return 'DETAIL'
+}
+
+const buildLogItem = ({
+ answer,
+ data,
+ messageId,
+}: {
+ answer?: string
+ data: Awaited>
+ messageId?: string | null
+}): IChatItem => {
+ const assistantFiles = data.message_files?.filter(file => file.belongs_to === 'assistant') || []
+ const normalizedMessage = typeof data.message === 'string'
+ ? { role: 'user', text: data.message }
+ : data.message
+ const baseLog = Array.isArray(normalizedMessage) ? normalizedMessage : [normalizedMessage]
+ const log = Array.isArray(normalizedMessage)
+ ? [
+ ...normalizedMessage,
+ ...(normalizedMessage.length > 0 && normalizedMessage[normalizedMessage.length - 1].role !== 'assistant'
+ ? [{
+ role: 'assistant',
+ text: answer || '',
+ files: assistantFiles,
+ }]
+ : []),
+ ]
+ : baseLog
+
+ return {
+ id: data.id || messageId || '',
+ content: answer || '',
+ isAnswer: true,
+ log,
+ message_files: data.message_files,
+ }
+}
+
+type UseGenerationItemParams = Pick<
+ IGenerationItemProps,
+ | 'appSourceType'
+ | 'content'
+ | 'controlClearMoreLikeThis'
+ | 'depth'
+ | 'installedAppId'
+ | 'isInWebApp'
+ | 'isLoading'
+ | 'isMobile'
+ | 'isShowTextToSpeech'
+ | 'isWorkflow'
+ | 'messageId'
+ | 'onRetry'
+ | 'onSave'
+ | 'siteInfo'
+ | 'taskId'
+ | 'workflowProcessData'
+>
+
+type MoreLikeThisState = {
+ childFeedback: FeedbackType
+ childMessageId: string | null
+ completionRes: string
+ controlVersion?: number
+}
+
+type CurrentTabState = {
+ signature: string
+ value: string | null
+}
+
+type MoreLikeThisResponse = {
+ answer?: string
+ id?: string
+}
+
+const getWorkflowTabSignature = (workflowProcessData?: WorkflowProcess) => JSON.stringify({
+ filesLength: workflowProcessData?.files?.length ?? 0,
+ humanInputFilledFormDataListLength: workflowProcessData?.humanInputFilledFormDataList?.length ?? 0,
+ humanInputFormDataListLength: workflowProcessData?.humanInputFormDataList?.length ?? 0,
+ resultText: workflowProcessData?.resultText ?? '',
+})
+
+export const useGenerationItem = ({
+ appSourceType,
+ content,
+ controlClearMoreLikeThis,
+ depth = 1,
+ installedAppId,
+ isInWebApp = false,
+ isLoading,
+ isMobile,
+ isShowTextToSpeech,
+ isWorkflow,
+ messageId,
+ onRetry,
+ onSave,
+ siteInfo,
+ taskId,
+ workflowProcessData,
+}: UseGenerationItemParams) => {
+ const { t } = useTranslation()
+ const params = useParams()
+ const { config } = useChatContext()
+
+ const setCurrentLogItem = useAppStore(state => state.setCurrentLogItem)
+ const setShowPromptLogModal = useAppStore(state => state.setShowPromptLogModal)
+
+ const workflowTabSignature = getWorkflowTabSignature(workflowProcessData)
+ const workflowDefaultTab = getCurrentTab(workflowProcessData)
+
+ const [moreLikeThisState, setMoreLikeThisState] = useState(() => ({
+ childFeedback: {
+ rating: null,
+ },
+ childMessageId: null,
+ completionRes: '',
+ controlVersion: controlClearMoreLikeThis,
+ }))
+ const [currentTabState, setCurrentTabState] = useState(() => ({
+ signature: workflowTabSignature,
+ value: null,
+ }))
+ const [isQuerying, { setTrue: startQuerying, setFalse: stopQuerying }] = useBoolean(false)
+
+ const isTop = depth === 1
+ const isTryApp = appSourceType === AppSourceType.tryApp
+ const taskLabel = taskId ? `${taskId}${depth > 1 ? `-${depth - 1}` : ''}` : ''
+ const isMoreLikeThisCleared = moreLikeThisState.controlVersion !== controlClearMoreLikeThis
+ const completionRes = isMoreLikeThisCleared ? '' : moreLikeThisState.completionRes
+ const childMessageId = (isLoading || isMoreLikeThisCleared) ? null : moreLikeThisState.childMessageId
+ const currentTab = currentTabState.signature === workflowTabSignature && currentTabState.value
+ ? currentTabState.value
+ : workflowDefaultTab
+
+ const handleChildFeedback = useCallback(async (nextFeedback: FeedbackType) => {
+ await updateFeedback(
+ {
+ url: `/messages/${childMessageId}/feedbacks`,
+ body: { rating: nextFeedback.rating },
+ },
+ appSourceType,
+ installedAppId,
+ )
+ setMoreLikeThisState(prev => ({
+ ...prev,
+ childFeedback: nextFeedback,
+ }))
+ }, [appSourceType, childMessageId, installedAppId])
+
+ const handleMoreLikeThis = useCallback(async () => {
+ if (isQuerying || !messageId) {
+ toast.warning(t('errorMessage.waitForResponse', { ns: 'appDebug' }))
+ return
+ }
+
+ startQuerying()
+ const response = await fetchMoreLikeThis(messageId, appSourceType, installedAppId) as MoreLikeThisResponse
+ setMoreLikeThisState({
+ childFeedback: { rating: null },
+ childMessageId: response.id ?? null,
+ completionRes: response.answer ?? '',
+ controlVersion: controlClearMoreLikeThis,
+ })
+ stopQuerying()
+ }, [appSourceType, controlClearMoreLikeThis, installedAppId, isQuerying, messageId, startQuerying, stopQuerying, t])
+
+ const handleOpenLogModal = useCallback(async () => {
+ const data = await fetchTextGenerationMessage({
+ appId: params.appId as string,
+ messageId: messageId!,
+ })
+ const logItem = buildLogItem({
+ answer: data.answer,
+ data,
+ messageId,
+ })
+
+ setCurrentLogItem(logItem)
+ setShowPromptLogModal(true)
+ }, [messageId, params.appId, setCurrentLogItem, setShowPromptLogModal])
+
+ const handleSubmitHumanInputForm = useCallback(async (
+ formToken: string,
+ formData: { inputs: Record, action: string },
+ ) => {
+ if (appSourceType === AppSourceType.installedApp) {
+ await submitHumanInputFormService(formToken, formData)
+ return
+ }
+
+ await submitHumanInputForm(formToken, formData)
+ }, [appSourceType])
+
+ const handleCopy = useCallback(() => {
+ const copyContent = isWorkflow ? workflowProcessData?.resultText : content
+ if (typeof copyContent === 'string')
+ copy(copyContent)
+ else
+ copy(JSON.stringify(copyContent))
+
+ toast.success(t('actionMsg.copySuccessfully', { ns: 'common' }))
+ }, [content, isWorkflow, t, workflowProcessData?.resultText])
+
+ const setCurrentTab = useCallback((tab: string) => {
+ setCurrentTabState({
+ signature: workflowTabSignature,
+ value: tab,
+ })
+ }, [workflowTabSignature])
+
+ const childProps: IGenerationItemProps = useMemo(() => ({
+ appSourceType,
+ content: completionRes,
+ controlClearMoreLikeThis,
+ depth: depth + 1,
+ feedback: moreLikeThisState.childFeedback,
+ installedAppId,
+ isError: false,
+ isInWebApp,
+ isLoading: isQuerying,
+ isMobile,
+ isShowTextToSpeech,
+ isWorkflow,
+ messageId: childMessageId,
+ moreLikeThis: true,
+ onFeedback: handleChildFeedback,
+ onRetry,
+ onSave,
+ siteInfo,
+ taskId,
+ }), [
+ appSourceType,
+ childMessageId,
+ completionRes,
+ controlClearMoreLikeThis,
+ depth,
+ handleChildFeedback,
+ installedAppId,
+ isInWebApp,
+ isMobile,
+ isQuerying,
+ isShowTextToSpeech,
+ isWorkflow,
+ moreLikeThisState.childFeedback,
+ onRetry,
+ onSave,
+ siteInfo,
+ taskId,
+ ])
+
+ return {
+ childMessageId,
+ childProps,
+ config,
+ completionRes,
+ currentTab,
+ handleCopy,
+ handleMoreLikeThis,
+ handleOpenLogModal,
+ handleSubmitHumanInputForm,
+ isQuerying,
+ isTop,
+ isTryApp,
+ setCurrentTab,
+ showChildItem: (childMessageId || isQuerying) && depth < MAX_DEPTH,
+ taskLabel,
+ }
+}
+
+export const generationItemHelpers = {
+ buildLogItem,
+ getCurrentTab,
+}
diff --git a/web/app/components/apps/__tests__/app-card-skeleton.spec.tsx b/web/app/components/apps/__tests__/app-card-skeleton.spec.tsx
new file mode 100644
index 0000000000..31095940bc
--- /dev/null
+++ b/web/app/components/apps/__tests__/app-card-skeleton.spec.tsx
@@ -0,0 +1,28 @@
+import { render } from '@testing-library/react'
+
+import { AppCardSkeleton } from '../app-card-skeleton'
+
+describe('AppCardSkeleton', () => {
+ it('should render six skeleton cards by default', () => {
+ const { container } = render()
+
+ expect(container.querySelectorAll('.bg-components-card-bg')).toHaveLength(6)
+ })
+
+ it('should render the configured number of skeleton cards', () => {
+ const { container } = render()
+
+ expect(container.querySelectorAll('.bg-components-card-bg')).toHaveLength(2)
+ expect(container.querySelectorAll('.animate-pulse')).toHaveLength(10)
+ })
+
+ it('should render nothing when count is zero', () => {
+ const { container } = render()
+
+ expect(container.firstChild).toBeNull()
+ })
+
+ it('should expose a stable display name', () => {
+ expect(AppCardSkeleton.displayName).toBe('AppCardSkeleton')
+ })
+})
diff --git a/web/app/components/apps/__tests__/import-from-marketplace-template-modal.spec.tsx b/web/app/components/apps/__tests__/import-from-marketplace-template-modal.spec.tsx
new file mode 100644
index 0000000000..88ee57153c
--- /dev/null
+++ b/web/app/components/apps/__tests__/import-from-marketplace-template-modal.spec.tsx
@@ -0,0 +1,329 @@
+import type { MarketplaceTemplate } from '@/service/marketplace-templates'
+import { skipToken } from '@tanstack/react-query'
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import * as React from 'react'
+
+import ImportFromMarketplaceTemplateModal from '../import-from-marketplace-template-modal'
+
+const {
+ mockUseQuery,
+ mockTemplateDetailQueryOptions,
+ mockFetchMarketplaceTemplateDSL,
+ mockToastError,
+} = vi.hoisted(() => ({
+ mockUseQuery: vi.fn(),
+ mockTemplateDetailQueryOptions: vi.fn(),
+ mockFetchMarketplaceTemplateDSL: vi.fn(),
+ mockToastError: vi.fn(),
+}))
+
+vi.mock('@tanstack/react-query', async () => {
+ const actual = await vi.importActual('@tanstack/react-query')
+ return {
+ ...actual,
+ useQuery: (options: unknown) => mockUseQuery(options),
+ }
+})
+
+vi.mock('react-i18next', () => ({
+ useTranslation: () => ({
+ t: (key: string, options?: Record) => {
+ if (options?.publisher)
+ return `${key}:${options.publisher}`
+ if (typeof options?.count === 'number')
+ return `${key}:${options.count}`
+ return key
+ },
+ }),
+}))
+
+vi.mock('@/config', () => ({
+ MARKETPLACE_API_PREFIX: 'https://marketplace.example/api/v1',
+ MARKETPLACE_URL_PREFIX: 'https://marketplace.example',
+}))
+
+vi.mock('@/service/client', () => ({
+ marketplaceQuery: {
+ templateDetail: {
+ queryOptions: (options: unknown) => mockTemplateDetailQueryOptions(options),
+ },
+ },
+}))
+
+vi.mock('@/service/marketplace-templates', async () => {
+ const actual = await vi.importActual('@/service/marketplace-templates')
+ return {
+ ...actual,
+ fetchMarketplaceTemplateDSL: (templateId: string) => mockFetchMarketplaceTemplateDSL(templateId),
+ }
+})
+
+vi.mock('@/app/components/base/app-icon', () => ({
+ default: (props: Record) => React.createElement('div', {
+ 'data-testid': 'app-icon',
+ 'data-icon-type': props.iconType,
+ 'data-icon': props.icon,
+ 'data-background': props.background,
+ 'data-image-url': props.imageUrl,
+ }),
+}))
+
+vi.mock('@/app/components/base/button', () => ({
+ default: ({ children, ...props }: React.ButtonHTMLAttributes) => React.createElement('button', props, children),
+}))
+
+vi.mock('@/app/components/base/ui/dialog', () => ({
+ Dialog: ({
+ children,
+ onOpenChange,
+ }: {
+ children: React.ReactNode
+ onOpenChange?: (open: boolean) => void
+ }) => React.createElement(
+ 'div',
+ { 'data-testid': 'dialog-root' },
+ React.createElement('button', {
+ 'data-testid': 'dialog-close',
+ 'onClick': () => onOpenChange?.(false),
+ }, 'close'),
+ children,
+ ),
+ DialogContent: ({ children, ...props }: React.HTMLAttributes) => React.createElement('div', props, children),
+ DialogTitle: ({ children, ...props }: React.HTMLAttributes) => React.createElement('div', props, children),
+ DialogCloseButton: (props: React.ButtonHTMLAttributes) => React.createElement('button', { ...props, 'data-testid': 'dialog-close-button' }, 'close'),
+}))
+
+vi.mock('@/app/components/base/ui/toast', () => ({
+ toast: {
+ error: (...args: unknown[]) => mockToastError(...args),
+ },
+}))
+
+const baseTemplate: MarketplaceTemplate = {
+ id: 'template-id',
+ publisher_type: 'individual',
+ publisher_unique_handle: 'publisher-handle',
+ template_name: 'Template Name',
+ icon: '๐',
+ icon_background: '#FFEAD5',
+ icon_file_key: 'icon-file',
+ kind: 'classic',
+ categories: [],
+ deps_plugins: [],
+ preferred_languages: [],
+ overview: 'Template overview',
+ readme: 'Template readme',
+ partner_link: '',
+ version: '1.0.0',
+ status: 'published',
+ usage_count: 3,
+ created_at: '2024-01-01T00:00:00Z',
+ updated_at: '2024-01-01T00:00:00Z',
+}
+
+describe('ImportFromMarketplaceTemplateModal', () => {
+ const onConfirm = vi.fn()
+ const onClose = vi.fn()
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockTemplateDetailQueryOptions.mockImplementation(options => options)
+ })
+
+ it('should request template detail for the provided template id', () => {
+ mockUseQuery.mockReturnValue({
+ data: { data: baseTemplate },
+ isLoading: false,
+ isError: false,
+ })
+
+ render(
+ ,
+ )
+
+ expect(mockTemplateDetailQueryOptions).toHaveBeenCalledWith({
+ input: {
+ params: {
+ templateId: 'template-id',
+ },
+ },
+ })
+ })
+
+ it('should pass skipToken when template id is empty', () => {
+ mockUseQuery.mockReturnValue({
+ data: undefined,
+ isLoading: false,
+ isError: false,
+ })
+
+ render(
+ ,
+ )
+
+ expect(mockTemplateDetailQueryOptions).toHaveBeenCalledWith({
+ input: skipToken,
+ })
+ })
+
+ it('should render loading state while fetching template detail', () => {
+ mockUseQuery.mockReturnValue({
+ data: undefined,
+ isLoading: true,
+ isError: false,
+ })
+
+ const { container } = render(
+ ,
+ )
+
+ const spinner = container.querySelector('.animate-spin')
+ expect(spinner).toBeInTheDocument()
+ expect(screen.queryByRole('link')).not.toBeInTheDocument()
+ })
+
+ it('should render error state and allow closing the modal', () => {
+ mockUseQuery.mockReturnValue({
+ data: undefined,
+ isLoading: false,
+ isError: true,
+ })
+
+ render(
+ ,
+ )
+
+ expect(screen.getByText('marketplace.template.fetchFailed')).toBeInTheDocument()
+
+ fireEvent.click(screen.getByText('newApp.Cancel'))
+ fireEvent.click(screen.getByTestId('dialog-close'))
+
+ expect(onClose).toHaveBeenCalledTimes(2)
+ })
+
+ it('should render template information and marketplace link', () => {
+ mockUseQuery.mockReturnValue({
+ data: { data: baseTemplate },
+ isLoading: false,
+ isError: false,
+ })
+
+ render(
+ ,
+ )
+
+ expect(screen.getByText('marketplace.template.modalTitle')).toBeInTheDocument()
+ expect(screen.getByText('Template Name')).toBeInTheDocument()
+ expect(screen.getByText('marketplace.template.publishedBy:publisher-handle')).toBeInTheDocument()
+ expect(screen.getByText('marketplace.template.overview')).toBeInTheDocument()
+ expect(screen.getByText('Template overview')).toBeInTheDocument()
+ expect(screen.getByText('marketplace.template.usageCount:3')).toBeInTheDocument()
+ expect(screen.getByRole('link', { name: 'marketplace.template.viewOnMarketplace' })).toHaveAttribute(
+ 'href',
+ 'https://marketplace.example/templates/template-id',
+ )
+ expect(screen.getByTestId('app-icon')).toHaveAttribute('data-icon-type', 'image')
+ expect(screen.getByTestId('app-icon')).toHaveAttribute(
+ 'data-image-url',
+ 'https://marketplace.example/api/v1/templates/template-id/icon',
+ )
+ })
+
+ it('should fall back to emoji icons and hide optional sections when data is missing', () => {
+ mockUseQuery.mockReturnValue({
+ data: {
+ data: {
+ ...baseTemplate,
+ icon_file_key: '',
+ overview: '',
+ usage_count: 0,
+ },
+ },
+ isLoading: false,
+ isError: false,
+ })
+
+ render(
+ ,
+ )
+
+ expect(screen.getByTestId('app-icon')).toHaveAttribute('data-icon-type', 'emoji')
+ expect(screen.queryByText('marketplace.template.overview')).not.toBeInTheDocument()
+ expect(screen.queryByText('marketplace.template.usageCount:0')).not.toBeInTheDocument()
+ })
+
+ it('should fetch the template DSL and confirm the import', async () => {
+ mockUseQuery.mockReturnValue({
+ data: { data: baseTemplate },
+ isLoading: false,
+ isError: false,
+ })
+ mockFetchMarketplaceTemplateDSL.mockResolvedValue('yaml-content')
+
+ render(
+ ,
+ )
+
+ fireEvent.click(screen.getByText('marketplace.template.importConfirm'))
+
+ await waitFor(() => {
+ expect(mockFetchMarketplaceTemplateDSL).toHaveBeenCalledWith('template-id')
+ expect(onConfirm).toHaveBeenCalledWith('yaml-content', baseTemplate)
+ })
+ })
+
+ it('should show a toast and re-enable the button when importing fails', async () => {
+ mockUseQuery.mockReturnValue({
+ data: { data: baseTemplate },
+ isLoading: false,
+ isError: false,
+ })
+ mockFetchMarketplaceTemplateDSL.mockRejectedValue(new Error('failed'))
+
+ render(
+ ,
+ )
+
+ const importButton = screen.getByText('marketplace.template.importConfirm')
+ fireEvent.click(importButton)
+
+ await waitFor(() => {
+ expect(mockToastError).toHaveBeenCalledWith('marketplace.template.importFailed')
+ })
+
+ expect(onConfirm).not.toHaveBeenCalled()
+ expect(screen.getByText('marketplace.template.importConfirm')).not.toBeDisabled()
+ })
+})
diff --git a/web/app/components/plugins/plugin-page/__tests__/debug-info.spec.tsx b/web/app/components/plugins/plugin-page/__tests__/debug-info.spec.tsx
new file mode 100644
index 0000000000..29cdeff819
--- /dev/null
+++ b/web/app/components/plugins/plugin-page/__tests__/debug-info.spec.tsx
@@ -0,0 +1,116 @@
+import type { DebugInfo as DebugInfoType } from '../../types'
+import { render, screen } from '@testing-library/react'
+import * as React from 'react'
+
+import DebugInfo from '../debug-info'
+
+const { mockUseDebugKey } = vi.hoisted(() => ({
+ mockUseDebugKey: vi.fn(),
+}))
+
+vi.mock('react-i18next', () => ({
+ useTranslation: () => ({
+ t: (key: string) => key,
+ }),
+}))
+
+vi.mock('@/context/i18n', () => ({
+ useDocLink: () => (path: string) => `https://docs.example.com${path}`,
+}))
+
+vi.mock('@/service/use-plugins', () => ({
+ useDebugKey: () => mockUseDebugKey(),
+}))
+
+vi.mock('@/app/components/base/button', () => ({
+ default: ({ children, ...props }: React.ButtonHTMLAttributes) => React.createElement('button', props, children),
+}))
+
+vi.mock('@/app/components/base/tooltip', () => ({
+ default: ({
+ children,
+ popupContent,
+ disabled,
+ }: {
+ children: React.ReactNode
+ popupContent: React.ReactNode
+ disabled?: boolean
+ }) => (
+
+ {children}
+ {!disabled &&
{popupContent}
}
+
+ ),
+}))
+
+vi.mock('../../base/key-value-item', () => ({
+ default: ({
+ label,
+ value,
+ maskedValue,
+ }: {
+ label: string
+ value: string
+ maskedValue?: string
+ }) => (
+
+ {label}
+ {value}
+ {maskedValue && {maskedValue}}
+
+ ),
+}))
+
+describe('DebugInfo', () => {
+ const debugInfo: DebugInfoType = {
+ host: '127.0.0.1',
+ port: 8765,
+ key: '12345678abcdefgh87654321',
+ }
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('should render nothing while loading', () => {
+ mockUseDebugKey.mockReturnValue({
+ data: undefined,
+ isLoading: true,
+ })
+
+ const { container } = render()
+
+ expect(container.firstChild).toBeNull()
+ })
+
+ it('should render a disabled tooltip when debug info is unavailable', () => {
+ mockUseDebugKey.mockReturnValue({
+ data: undefined,
+ isLoading: false,
+ })
+
+ render()
+
+ expect(screen.getByTestId('tooltip')).toHaveAttribute('data-disabled', 'true')
+ expect(screen.queryByTestId('tooltip-content')).not.toBeInTheDocument()
+ })
+
+ it('should render masked debug information and docs link when data is available', () => {
+ mockUseDebugKey.mockReturnValue({
+ data: debugInfo,
+ isLoading: false,
+ })
+
+ render()
+
+ expect(screen.getAllByTestId('tooltip')[0]).toHaveAttribute('data-disabled', 'false')
+ expect(screen.getByText('debugInfo.title')).toBeInTheDocument()
+ expect(screen.getByRole('link', { name: 'debugInfo.viewDocs' })).toHaveAttribute(
+ 'href',
+ 'https://docs.example.com/develop-plugin/features-and-specs/plugin-types/remote-debug-a-plugin',
+ )
+ expect(screen.getByTestId('key-value-URL')).toHaveTextContent('127.0.0.1:8765')
+ expect(screen.getByTestId('key-value-Key')).toHaveTextContent('12345678abcdefgh87654321')
+ expect(screen.getByTestId('key-value-Key')).toHaveTextContent('12345678********87654321')
+ })
+})
diff --git a/web/app/components/plugins/plugin-page/__tests__/plugins-panel.spec.tsx b/web/app/components/plugins/plugin-page/__tests__/plugins-panel.spec.tsx
new file mode 100644
index 0000000000..4eac3f17ca
--- /dev/null
+++ b/web/app/components/plugins/plugin-page/__tests__/plugins-panel.spec.tsx
@@ -0,0 +1,369 @@
+import type { PluginDeclaration, PluginDetail } from '../../types'
+import { fireEvent, render, screen } from '@testing-library/react'
+import * as React from 'react'
+import { PluginCategoryEnum, PluginSource } from '../../types'
+
+import PluginsPanel from '../plugins-panel'
+
+const {
+ mockSetFilters,
+ mockSetCurrentPluginID,
+ mockLoadNextPage,
+ mockInvalidateInstalledPluginList,
+} = vi.hoisted(() => ({
+ mockSetFilters: vi.fn(),
+ mockSetCurrentPluginID: vi.fn(),
+ mockLoadNextPage: vi.fn(),
+ mockInvalidateInstalledPluginList: vi.fn(),
+}))
+
+type PluginPageContextState = {
+ filters: {
+ categories: string[]
+ tags: string[]
+ searchQuery: string
+ }
+ setFilters: (filters: {
+ categories: string[]
+ tags: string[]
+ searchQuery: string
+ }) => void
+ currentPluginID: string | undefined
+ setCurrentPluginID: (pluginID?: string) => void
+}
+
+let mockContextState: PluginPageContextState
+let mockPluginList: PluginDetail[]
+let mockPluginListLoading = false
+let mockPluginListFetching = false
+let mockIsLastPage = true
+
+vi.mock('ahooks', () => ({
+ useDebounceFn: (fn: (...args: unknown[]) => void) => ({
+ run: fn,
+ }),
+}))
+
+vi.mock('react-i18next', () => ({
+ useTranslation: () => ({
+ t: (key: string) => key,
+ }),
+}))
+
+vi.mock('@/context/i18n', () => ({
+ useGetLanguage: () => 'en-US',
+}))
+
+vi.mock('@/i18n-config', () => ({
+ renderI18nObject: (value: Record | undefined, locale: string) =>
+ value?.[locale] ?? value?.['en-US'] ?? '',
+}))
+
+vi.mock('@/service/use-plugins', () => ({
+ useInstalledPluginList: () => ({
+ data: { plugins: mockPluginList },
+ isLoading: mockPluginListLoading,
+ isFetching: mockPluginListFetching,
+ isLastPage: mockIsLastPage,
+ loadNextPage: mockLoadNextPage,
+ }),
+ useInvalidateInstalledPluginList: () => mockInvalidateInstalledPluginList,
+}))
+
+vi.mock('../../hooks', () => ({
+ usePluginsWithLatestVersion: (plugins: PluginDetail[] | undefined) => plugins ?? [],
+}))
+
+vi.mock('../context', () => ({
+ usePluginPageContext: (selector: (value: PluginPageContextState) => unknown) => selector(mockContextState),
+}))
+
+vi.mock('../filter-management', () => ({
+ default: ({ onFilterChange }: {
+ onFilterChange: (filters: {
+ categories: string[]
+ tags: string[]
+ searchQuery: string
+ }) => void
+ }) => (
+
+ ),
+}))
+
+vi.mock('../list', () => ({
+ default: ({ pluginList }: { pluginList: PluginDetail[] }) => (
+
+ {pluginList.map(plugin => plugin.plugin_id).join(',')}
+
+ ),
+}))
+
+vi.mock('../empty', () => ({
+ default: () => empty
,
+}))
+
+vi.mock('@/app/components/base/loading', () => ({
+ default: ({ className, type }: { className?: string, type?: string }) => (
+ loading
+ ),
+}))
+
+vi.mock('@/app/components/base/button', () => ({
+ default: ({ children, ...props }: React.ButtonHTMLAttributes) => React.createElement('button', props, children),
+}))
+
+vi.mock('@/app/components/plugins/plugin-detail-panel', () => ({
+ default: ({
+ detail,
+ onUpdate,
+ onHide,
+ }: {
+ detail?: PluginDetail
+ onUpdate: () => void
+ onHide: () => void
+ }) => (
+
+ {detail?.plugin_id ?? 'none'}
+
+
+
+ ),
+}))
+
+const createPluginDeclaration = (overrides: Partial = {}): PluginDeclaration => ({
+ plugin_unique_identifier: 'plugin.unique',
+ version: '1.0.0',
+ author: 'Plugin Author',
+ icon: 'icon',
+ name: 'Declaration Name',
+ category: PluginCategoryEnum.tool,
+ label: { 'en-US': 'Label Text' } as PluginDeclaration['label'],
+ description: { 'en-US': 'Description Text' } as PluginDeclaration['description'],
+ created_at: '2024-01-01T00:00:00Z',
+ resource: {} as PluginDeclaration['resource'],
+ plugins: {} as PluginDeclaration['plugins'],
+ verified: false,
+ endpoint: { settings: [], endpoints: [] },
+ tool: undefined,
+ datasource: undefined,
+ model: {} as PluginDeclaration['model'],
+ tags: ['featured'],
+ agent_strategy: {} as PluginDeclaration['agent_strategy'],
+ meta: { version: '1.0.0' },
+ trigger: {} as PluginDeclaration['trigger'],
+ ...overrides,
+})
+
+const createPlugin = (overrides: Partial = {}): PluginDetail => ({
+ id: 'plugin-id',
+ created_at: '2024-01-01T00:00:00Z',
+ updated_at: '2024-01-01T00:00:00Z',
+ name: 'Plugin Name',
+ plugin_id: 'plugin.id',
+ plugin_unique_identifier: 'plugin.unique',
+ declaration: createPluginDeclaration(),
+ installation_id: 'installation-id',
+ tenant_id: 'tenant-id',
+ endpoints_setups: 0,
+ endpoints_active: 0,
+ version: '1.0.0',
+ latest_version: '1.0.1',
+ latest_unique_identifier: 'plugin.unique@1.0.1',
+ source: PluginSource.marketplace,
+ status: 'active',
+ deprecated_reason: '',
+ alternative_plugin_id: '',
+ ...overrides,
+})
+
+describe('PluginsPanel', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockContextState = {
+ filters: {
+ categories: [],
+ tags: [],
+ searchQuery: '',
+ },
+ setFilters: mockSetFilters,
+ currentPluginID: undefined,
+ setCurrentPluginID: mockSetCurrentPluginID,
+ }
+ mockPluginList = [createPlugin()]
+ mockPluginListLoading = false
+ mockPluginListFetching = false
+ mockIsLastPage = true
+ })
+
+ it('should render loading while the plugin list is loading', () => {
+ mockPluginListLoading = true
+
+ render()
+
+ expect(screen.getByTestId('loading')).toHaveAttribute('data-type', 'app')
+ expect(screen.getByTestId('plugin-detail-id')).toHaveTextContent('none')
+ })
+
+ it('should update filters through the debounced callback', () => {
+ render()
+
+ fireEvent.click(screen.getByTestId('filter-management'))
+
+ expect(mockSetFilters).toHaveBeenCalledWith({
+ categories: ['tool'],
+ tags: ['featured'],
+ searchQuery: 'needle',
+ })
+ })
+
+ it('should filter plugins by category and tag', () => {
+ mockContextState.filters = {
+ categories: ['tool'],
+ tags: ['featured'],
+ searchQuery: '',
+ }
+
+ render()
+
+ expect(screen.getByTestId('plugin-list')).toHaveTextContent('plugin.id')
+ })
+
+ it('should filter plugins by plugin id', () => {
+ const plugin = createPlugin({
+ plugin_id: 'needle-id',
+ name: 'Plugin Name',
+ declaration: createPluginDeclaration({
+ name: 'Declaration Name',
+ label: { 'en-US': 'Label Text' } as PluginDeclaration['label'],
+ description: { 'en-US': 'Description Text' } as PluginDeclaration['description'],
+ }),
+ })
+
+ mockPluginList = [plugin]
+ mockContextState.filters = {
+ categories: [],
+ tags: [],
+ searchQuery: 'needle-id',
+ }
+
+ render()
+
+ expect(screen.getByTestId('plugin-list')).toHaveTextContent('needle-id')
+ })
+
+ it('should filter plugins by plugin name, declaration name, label, and description', () => {
+ const plugin = createPlugin({
+ plugin_id: 'plugin-id',
+ name: 'Needle Name',
+ declaration: createPluginDeclaration({
+ name: 'Needle Declaration',
+ label: { 'en-US': 'Needle Label' } as PluginDeclaration['label'],
+ description: { 'en-US': 'Needle Description' } as PluginDeclaration['description'],
+ }),
+ })
+
+ mockPluginList = [plugin]
+ const { rerender } = render()
+
+ mockContextState.filters = {
+ categories: [],
+ tags: [],
+ searchQuery: 'needle name',
+ }
+ rerender()
+ expect(screen.getByTestId('plugin-list')).toHaveTextContent('plugin-id')
+
+ mockContextState.filters = {
+ categories: [],
+ tags: [],
+ searchQuery: 'needle declaration',
+ }
+ rerender()
+ expect(screen.getByTestId('plugin-list')).toHaveTextContent('plugin-id')
+
+ mockContextState.filters = {
+ categories: [],
+ tags: [],
+ searchQuery: 'needle label',
+ }
+ rerender()
+ expect(screen.getByTestId('plugin-list')).toHaveTextContent('plugin-id')
+
+ mockContextState.filters = {
+ categories: [],
+ tags: [],
+ searchQuery: 'needle description',
+ }
+ rerender()
+ expect(screen.getByTestId('plugin-list')).toHaveTextContent('plugin-id')
+ })
+
+ it('should render empty state when no plugin matches the filters', () => {
+ mockContextState.filters = {
+ categories: ['model'],
+ tags: [],
+ searchQuery: '',
+ }
+
+ render()
+
+ expect(screen.getByTestId('empty')).toBeInTheDocument()
+ expect(screen.queryByTestId('plugin-list')).not.toBeInTheDocument()
+ })
+
+ it('should render empty state when the search query does not match any plugin field', () => {
+ mockContextState.filters = {
+ categories: [],
+ tags: [],
+ searchQuery: 'missing-value',
+ }
+
+ render()
+
+ expect(screen.getByTestId('empty')).toBeInTheDocument()
+ expect(screen.queryByTestId('plugin-list')).not.toBeInTheDocument()
+ })
+
+ it('should render a load more button and request the next page', () => {
+ mockIsLastPage = false
+
+ render()
+
+ fireEvent.click(screen.getByText('common.loadMore'))
+
+ expect(mockLoadNextPage).toHaveBeenCalledTimes(1)
+ })
+
+ it('should render the inline loader when fetching more items', () => {
+ mockIsLastPage = false
+ mockPluginListFetching = true
+
+ render()
+
+ expect(screen.getByTestId('loading')).toHaveAttribute('data-class-name', 'size-8')
+ expect(screen.queryByText('common.loadMore')).not.toBeInTheDocument()
+ })
+
+ it('should pass the selected plugin to the detail panel and handle its callbacks', () => {
+ mockContextState.currentPluginID = 'plugin.id'
+
+ render()
+
+ expect(screen.getByTestId('plugin-detail-id')).toHaveTextContent('plugin.id')
+
+ fireEvent.click(screen.getByTestId('plugin-detail-update'))
+ fireEvent.click(screen.getByTestId('plugin-detail-hide'))
+
+ expect(mockInvalidateInstalledPluginList).toHaveBeenCalledTimes(1)
+ expect(mockSetCurrentPluginID).toHaveBeenCalledWith(undefined)
+ })
+})