refactor(web): remove configuration mitt context (#39796)

This commit is contained in:
yyh 2026-07-30 15:07:43 +08:00 committed by GitHub
parent e4947811a5
commit 95166806ae
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 167 additions and 152 deletions

View File

@ -458,7 +458,7 @@
"count": 2
},
"typescript/no-explicit-any": {
"count": 9
"count": 7
}
},
"web/app/components/app/configuration/config/agent/agent-tools/setting-built-in-tool.tsx": {

View File

@ -1,9 +1,14 @@
import type { ComponentProps } from 'react'
import type { ConfigurationViewModel } from '../hooks/use-configuration'
import type AppPublisher from '@/app/components/app/app-publisher/features-wrapper'
import type { InstallBundleCompleteCallback } from '@/app/components/plugins/install-plugin/install-bundle'
import type { Plugin } from '@/app/components/plugins/types'
import type ConfigContext from '@/context/debug-configuration'
import type { AgentTool } from '@/types/app'
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import { PluginCategoryEnum } from '@/app/components/plugins/types'
import { CollectionType } from '@/app/components/tools/types'
import { AppModeEnum, ModelModeType } from '@/types/app'
import ConfigurationView from '../configuration-view'
@ -48,10 +53,49 @@ vi.mock('@/app/components/base/features/new-feature-panel', () => ({
default: () => <div data-testid="feature-panel" />,
}))
let pluginDependencyOnInstallComplete: InstallBundleCompleteCallback | undefined
vi.mock('@/app/components/workflow/plugin-dependency', () => ({
default: () => <div data-testid="plugin-dependency" />,
default: ({ onInstallComplete }: { onInstallComplete?: InstallBundleCompleteCallback }) => {
pluginDependencyOnInstallComplete = onInstallComplete
return <div data-testid="plugin-dependency" />
},
}))
const createPlugin = (name: string): Plugin => ({
type: 'plugin',
org: 'vendor',
name,
plugin_id: 'vendor',
version: '1.0.0',
latest_version: '1.0.0',
latest_package_identifier: `vendor/${name}:1.0.0`,
icon: 'icon.svg',
verified: true,
label: { 'en-US': name },
brief: { 'en-US': name },
description: { 'en-US': name },
introduction: '',
repository: `https://example.com/vendor/${name}`,
category: PluginCategoryEnum.tool,
install_count: 0,
endpoint: { settings: [] },
tags: [],
badges: [],
verification: { authorized_category: 'community' },
from: 'marketplace',
})
const createDeletedAgentTool = (providerId: string): AgentTool => ({
provider_id: providerId,
provider_type: CollectionType.builtIn,
provider_name: providerId,
tool_name: 'search',
tool_label: 'Search',
tool_parameters: {},
enabled: true,
isDeleted: true,
})
const createContextValue = (): ComponentProps<typeof ConfigContext.Provider>['value'] => ({
appId: 'app-1',
isAPIKeySet: true,
@ -269,6 +313,7 @@ describe('ConfigurationView', () => {
beforeEach(() => {
vi.clearAllMocks()
mockIsAgentV2Enabled.mockReturnValue(false)
pluginDependencyOnInstallComplete = undefined
})
it('should render a loading state before configuration data is ready', () => {
@ -332,4 +377,63 @@ describe('ConfigurationView', () => {
expect(screen.queryByText('appDebug.legacyAgentBadge.label')).not.toBeInTheDocument()
})
it('should reinstate selected plugin tools when at least one installation succeeds', () => {
const contextValue = createContextValue()
contextValue.isAgent = true
const tools = [
createDeletedAgentTool('vendor/successful-plugin'),
createDeletedAgentTool('vendor/failed-plugin'),
createDeletedAgentTool('vendor/unselected-plugin'),
]
contextValue.modelConfig.agentConfig.tools = tools
render(<ConfigurationView {...createViewModel({ contextValue, isAgent: true })} />)
if (!pluginDependencyOnInstallComplete)
throw new Error('Plugin completion callback not registered')
pluginDependencyOnInstallComplete(
[createPlugin('successful-plugin'), createPlugin('failed-plugin')],
[
{ success: true, isFromMarketPlace: true },
{ success: false, isFromMarketPlace: true },
],
[
{ hasInstalled: false, toInstallVersion: '1.0.0' },
{ hasInstalled: false, toInstallVersion: '1.0.0' },
],
)
expect(contextValue.setModelConfig).toHaveBeenCalledOnce()
const nextModelConfig = vi.mocked(contextValue.setModelConfig).mock.calls[0]![0]
expect(nextModelConfig.agentConfig.tools).toEqual([
expect.objectContaining({ provider_id: 'vendor/successful-plugin', isDeleted: false }),
expect.objectContaining({ provider_id: 'vendor/failed-plugin', isDeleted: false }),
expect.objectContaining({ provider_id: 'vendor/unselected-plugin', isDeleted: true }),
])
})
it('should keep deleted tools unchanged when every installation fails', () => {
const contextValue = createContextValue()
contextValue.isAgent = true
contextValue.modelConfig.agentConfig.tools = [createDeletedAgentTool('vendor/failed-plugin')]
render(<ConfigurationView {...createViewModel({ contextValue, isAgent: true })} />)
if (!pluginDependencyOnInstallComplete)
throw new Error('Plugin completion callback not registered')
pluginDependencyOnInstallComplete(
[createPlugin('failed-plugin')],
[{ success: false, isFromMarketPlace: true }],
[{ hasInstalled: false, toInstallVersion: '1.0.0' }],
)
expect(contextValue.setModelConfig).not.toHaveBeenCalled()
})
it('should not attach the agent tool completion handler to non-agent configurations', () => {
render(<ConfigurationView {...createViewModel()} />)
expect(pluginDependencyOnInstallComplete).toBeUndefined()
})
})

View File

@ -8,7 +8,7 @@ import type { ToolDefaultValue } from '@/app/components/workflow/block-selector/
import type { ToolWithProvider } from '@/app/components/workflow/types'
import type { ModelConfig } from '@/models/debug'
import type { AgentTool } from '@/types/app'
import { act, render, screen, waitFor } from '@testing-library/react'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { useEffect, useMemo, useState } from 'react'
@ -28,17 +28,6 @@ vi.mock('@/app/components/app/configuration/debug/hooks', () => ({
useFormattingChangedDispatcher: () => formattingDispatcherMock,
}))
let pluginInstallHandler: ((names: string[]) => void) | null = null
const subscribeMock = vi.fn((event: string, handler: any) => {
if (event === 'plugin:install:success') pluginInstallHandler = handler
})
vi.mock('@/context/mitt-context', () => ({
useMittContextSelector: (selector: any) =>
selector({
useSubscribe: subscribeMock,
}),
}))
let builtInTools: ToolWithProvider[] = []
let customTools: ToolWithProvider[] = []
let workflowTools: ToolWithProvider[] = []
@ -326,7 +315,6 @@ describe('AgentTools', () => {
latestSettingPanelProps = null
settingPanelSavePayload = {}
settingPanelCredentialId = 'credential-from-panel'
pluginInstallHandler = null
})
it('should show enabled count and provider information', () => {
@ -493,27 +481,4 @@ describe('AgentTools', () => {
])
})
})
it('should reinstate deleted tools after plugin install success event', async () => {
const { getModelConfig } = renderAgentTools([
createAgentTool({
provider_id: 'provider-1',
provider_name: 'vendor/provider-1',
tool_name: 'search',
tool_label: 'Search Tool',
isDeleted: true,
}),
])
if (!pluginInstallHandler) throw new Error('Plugin handler not registered')
await act(async () => {
pluginInstallHandler?.(['provider-1'])
})
await waitFor(() => {
expect((getModelConfig().agentConfig.tools[0] as { isDeleted: boolean }).isDeleted).toBe(
false,
)
})
})
})

View File

@ -31,7 +31,6 @@ import {
import ToolPicker from '@/app/components/workflow/block-selector/tool-picker'
import { MAX_TOOLS_NUM } from '@/config'
import ConfigContext from '@/context/debug-configuration'
import { useMittContextSelector } from '@/context/mitt-context'
import {
useAllBuiltInTools,
useAllCustomTools,
@ -77,21 +76,6 @@ const AgentTools: FC = () => {
collection,
}
})
const useSubscribe = useMittContextSelector((s) => s.useSubscribe)
const handleUpdateToolsWhenInstallToolSuccess = useCallback(
(installedPluginNames: string[]) => {
const newModelConfig = produce(modelConfig, (draft) => {
draft.agentConfig.tools.forEach((item: any) => {
if (item.isDeleted && installedPluginNames.includes(item.provider_id))
item.isDeleted = false
})
})
setModelConfig(newModelConfig)
},
[modelConfig, setModelConfig],
)
useSubscribe('plugin:install:success', handleUpdateToolsWhenInstallToolSuccess as any)
const handleToolSettingChange = (value: Record<string, any>) => {
const newModelConfig = produce(modelConfig, (draft) => {
const tool = draft.agentConfig.tools.find(

View File

@ -1,6 +1,7 @@
'use client'
import type { FC } from 'react'
import type { ConfigurationViewModel } from './hooks/use-configuration'
import type { InstallBundleCompleteCallback } from '@/app/components/plugins/install-plugin/install-bundle'
import { CodeBracketIcon } from '@heroicons/react/20/solid'
import {
AlertDialog,
@ -22,6 +23,7 @@ import {
DrawerViewport,
} from '@langgenius/dify-ui/drawer'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import { produce } from 'immer'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import AppPublisher from '@/app/components/app/app-publisher/features-wrapper'
@ -37,7 +39,6 @@ import Loading from '@/app/components/base/loading'
import ModelParameterModal from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal'
import PluginDependency from '@/app/components/workflow/plugin-dependency'
import ConfigContext from '@/context/debug-configuration'
import { MittProvider } from '@/context/mitt-context-provider'
import { isAgentV2Enabled } from '@/features/agent-v2/feature-flag'
import Link from '@/next/link'
import { AppModeEnum, ModelModeType } from '@/types/app'
@ -117,6 +118,23 @@ const ConfigurationView: FC<ConfigurationViewModel> = ({
const { t } = useTranslation()
const debugWithMultipleModel = appPublisherProps.debugWithMultipleModel
const showLegacyAgentBadge = isAgentV2Enabled() && contextValue.mode === AppModeEnum.AGENT_CHAT
const handlePluginInstallComplete: InstallBundleCompleteCallback = (plugins, installStatus) => {
if (!installStatus.some((status) => status.success)) return
const installedPluginNames = plugins.map((plugin) => `${plugin.plugin_id}/${plugin.name}`)
const nextModelConfig = produce(contextValue.modelConfig, (draft) => {
draft.agentConfig.tools.forEach((tool) => {
if (
'provider_id' in tool &&
tool.isDeleted &&
installedPluginNames.includes(tool.provider_id)
) {
tool.isDeleted = false
}
})
})
contextValue.setModelConfig(nextModelConfig)
}
if (showLoading) {
return (
@ -129,7 +147,7 @@ const ConfigurationView: FC<ConfigurationViewModel> = ({
return (
<ConfigContext.Provider value={contextValue}>
<FeaturesProvider features={featuresData}>
<MittProvider>
<>
<div className="flex h-full flex-col">
<div className="relative flex h-50 grow pt-14">
<div className="bg-default-subtle absolute top-0 left-0 h-14 w-full">
@ -312,8 +330,8 @@ const ConfigurationView: FC<ConfigurationViewModel> = ({
onAutoAddPromptVariable={onAutoAddPromptVariable}
/>
)}
<PluginDependency />
</MittProvider>
<PluginDependency onInstallComplete={isAgent ? handlePluginInstallComplete : undefined} />
</>
</FeaturesProvider>
</ConfigContext.Provider>
)

View File

@ -188,11 +188,6 @@ vi.mock('@/config', async () => {
}
})
// Mock mitt context
vi.mock('@/context/mitt-context', () => ({
useMittContextSelector: () => vi.fn(),
}))
// Mock useCanInstallPluginFromMarketplace
vi.mock('@/app/components/plugins/plugin-page/use-reference-setting', () => ({
useCanInstallPluginFromMarketplace: () => ({ canInstallPluginFromMarketplace: true }),

View File

@ -100,6 +100,7 @@ describe('ReadyToInstall', () => {
const mockOnStartToInstall = vi.fn()
const mockSetIsInstalling = vi.fn()
const mockOnClose = vi.fn()
const mockOnInstallComplete = vi.fn()
const defaultProps = {
step: InstallStep.readyToInstall,
@ -108,6 +109,7 @@ describe('ReadyToInstall', () => {
setIsInstalling: mockSetIsInstalling,
allPlugins: createMockDependencies(),
onClose: mockOnClose,
onInstallComplete: mockOnInstallComplete,
}
beforeEach(() => {
@ -177,6 +179,11 @@ describe('ReadyToInstall', () => {
expect(screen.getByTestId('installed-step')).toBeInTheDocument()
expect(screen.getByTestId('installed-count')).toHaveTextContent('1')
expect(screen.getByTestId('installed-status-count')).toHaveTextContent('1')
expect(mockOnInstallComplete).toHaveBeenCalledWith(
[expect.objectContaining({ plugin_id: 'p1' })],
[expect.objectContaining({ success: true })],
[expect.objectContaining({ toInstallVersion: '1.0.0' })],
)
})
it('should pass custom plugins and status via capturedOnInstalled', () => {

View File

@ -1,6 +1,6 @@
'use client'
import type { FC } from 'react'
import type { Dependency } from '../../types'
import type { Dependency, InstallStatus, Plugin, VersionProps } from '../../types'
import { cn } from '@langgenius/dify-ui/cn'
import { Dialog, DialogCloseButton, DialogContent } from '@langgenius/dify-ui/dialog'
import * as React from 'react'
@ -12,6 +12,12 @@ import ReadyToInstall from './ready-to-install'
const i18nPrefix = 'installModal'
export type InstallBundleCompleteCallback = (
plugins: Plugin[],
installStatus: InstallStatus[],
versionInfo: VersionProps[],
) => void
export enum InstallType {
fromLocal = 'fromLocal',
fromMarketplace = 'fromMarketplace',
@ -23,12 +29,14 @@ type Props = Readonly<{
fromDSLPayload: Dependency[]
// plugins?: PluginDeclaration[]
onClose: () => void
onInstallComplete?: InstallBundleCompleteCallback
}>
const InstallBundle: FC<Props> = ({
installType = InstallType.fromMarketplace,
fromDSLPayload,
onClose,
onInstallComplete,
}) => {
const { t } = useTranslation()
const [step, setStep] = useState<InstallStep>(
@ -78,6 +86,7 @@ const InstallBundle: FC<Props> = ({
setIsInstalling={setIsInstalling}
allPlugins={fromDSLPayload}
onClose={onClose}
onInstallComplete={onInstallComplete}
/>
</DialogContent>
</Dialog>

View File

@ -1,6 +1,7 @@
'use client'
import type { FC } from 'react'
import type { Dependency, InstallStatus, Plugin, VersionProps } from '../../types'
import type { InstallBundleCompleteCallback } from './index'
import * as React from 'react'
import { useCallback, useState } from 'react'
import { InstallStep } from '../../types'
@ -14,6 +15,7 @@ type Props = Readonly<{
setIsInstalling: (isInstalling: boolean) => void
allPlugins: Dependency[]
onClose: () => void
onInstallComplete?: InstallBundleCompleteCallback
isFromMarketPlace?: boolean
}>
@ -24,6 +26,7 @@ const ReadyToInstall: FC<Props> = ({
setIsInstalling,
allPlugins,
onClose,
onInstallComplete,
isFromMarketPlace,
}) => {
const [installedPlugins, setInstalledPlugins] = useState<Plugin[]>([])
@ -36,8 +39,9 @@ const ReadyToInstall: FC<Props> = ({
setInstalledVersionInfo(versionInfo)
onStepChange(InstallStep.installed)
setIsInstalling(false)
onInstallComplete?.(plugins, installStatus, versionInfo)
},
[onStepChange, setIsInstalling],
[onInstallComplete, onStepChange, setIsInstalling],
)
return (
<>

View File

@ -55,12 +55,6 @@ vi.mock('../../../hooks/use-refresh-plugin-list', () => ({
}),
}))
// Mock mitt context
const mockEmit = vi.fn()
vi.mock('@/context/mitt-context', () => ({
useMittContextSelector: () => mockEmit,
}))
// Mock useCanInstallPluginFromMarketplace
vi.mock('@/app/components/plugins/plugin-page/use-reference-setting', () => ({
useCanInstallPluginFromMarketplace: () => ({ canInstallPluginFromMarketplace: true }),
@ -530,25 +524,6 @@ describe('Install Component', () => {
})
})
it('should emit plugin:install:success event on successful installation', async () => {
render(<Install {...defaultProps} />)
// Select all plugins
await act(async () => {
fireEvent.click(screen.getByTestId('select-all-plugins'))
})
// Click install
const installButton = screen.getByText(/plugin\.installModal\.install/i)
await act(async () => {
fireEvent.click(installButton)
})
await waitFor(() => {
expect(mockEmit).toHaveBeenCalledWith('plugin:install:success', expect.any(Array))
})
})
it('should disable install button when no plugins are selected', async () => {
render(<Install {...defaultProps} />)

View File

@ -1,13 +1,7 @@
'use client'
import type { FC } from 'react'
import type {
Dependency,
InstallStatus,
InstallStatusResponse,
Plugin,
VersionInfo,
VersionProps,
} from '../../../types'
import type { Dependency, InstallStatusResponse, Plugin, VersionInfo } from '../../../types'
import type { InstallBundleCompleteCallback } from '../index'
import type { ExposeRefs } from './install-multi'
import { Button } from '@langgenius/dify-ui/button'
import { Checkbox } from '@langgenius/dify-ui/checkbox'
@ -16,7 +10,6 @@ import * as React from 'react'
import { useCallback, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useCanInstallPluginFromMarketplace } from '@/app/components/plugins/plugin-page/use-reference-setting'
import { useMittContextSelector } from '@/context/mitt-context'
import { useInstallOrUpdate, usePluginTaskList } from '@/service/use-plugins'
import { TaskStatus } from '../../../types'
import checkTaskStatus from '../../base/check-task-status'
@ -29,11 +22,7 @@ const i18nPrefix = 'installModal'
type Props = Readonly<{
allPlugins: Dependency[]
onStartToInstall?: () => void
onInstalled: (
plugins: Plugin[],
installStatus: InstallStatus[],
versionInfo: VersionProps[],
) => void
onInstalled: InstallBundleCompleteCallback
onCancel: () => void
isFromMarketPlace?: boolean
isHideButton?: boolean
@ -48,7 +37,6 @@ const Install: FC<Props> = ({
isHideButton,
}) => {
const { t } = useTranslation()
const emit = useMittContextSelector((s) => s.emit)
const [selectedPlugins, setSelectedPlugins] = React.useState<Plugin[]>([])
const [selectedIndexes, setSelectedIndexes] = React.useState<number[]>([])
const selectedPluginsNum = selectedPlugins.length
@ -113,12 +101,6 @@ const Install: FC<Props> = ({
const hasInstallSuccess = res.some((r) => r.status === TaskStatus.success)
if (hasInstallSuccess) {
refreshPluginList(undefined, true)
emit(
'plugin:install:success',
selectedPlugins.map((p) => {
return `${p.plugin_id}/${p.name}`
}),
)
}
return
}
@ -143,15 +125,6 @@ const Install: FC<Props> = ({
}),
)
onInstalled(selectedPlugins, installStatus, getSelectedVersionInfo())
const hasInstallSuccess = installStatus.some((r) => r.success)
if (hasInstallSuccess) {
emit(
'plugin:install:success',
selectedPlugins.map((p) => {
return `${p.plugin_id}/${p.name}`
}),
)
}
},
})
const handleInstall = () => {

View File

@ -1,10 +1,15 @@
'use client'
import type { InstallBundleCompleteCallback } from '@/app/components/plugins/install-plugin/install-bundle'
import { useCallback } from 'react'
import InstallBundle from '@/app/components/plugins/install-plugin/install-bundle'
import { useStore } from './store'
const PluginDependency = () => {
type Props = {
onInstallComplete?: InstallBundleCompleteCallback
}
const PluginDependency = ({ onInstallComplete }: Props) => {
const dependencies = useStore((s) => s.dependencies)
const handleCancelInstallBundle = useCallback(() => {
@ -14,7 +19,13 @@ const PluginDependency = () => {
if (!dependencies.length) return null
return <InstallBundle fromDSLPayload={dependencies} onClose={handleCancelInstallBundle} />
return (
<InstallBundle
fromDSLPayload={dependencies}
onClose={handleCancelInstallBundle}
onInstallComplete={onInstallComplete}
/>
)
}
export default PluginDependency

View File

@ -1,15 +0,0 @@
'use client'
import type { ReactNode } from 'react'
import { useMitt } from '@/hooks/use-mitt'
import { MittContext } from './mitt-context'
type MittProviderProps = {
children: ReactNode
}
export const MittProvider = ({ children }: MittProviderProps) => {
const mitt = useMitt()
return <MittContext.Provider value={mitt}>{children}</MittContext.Provider>
}

View File

@ -1,15 +0,0 @@
'use client'
import type { useMitt } from '@/hooks/use-mitt'
import { noop } from 'es-toolkit/function'
import { createContext, useContextSelector } from 'use-context-selector'
type ContextValueType = ReturnType<typeof useMitt>
export const MittContext = createContext<ContextValueType>({
emit: noop,
useSubscribe: noop,
})
export function useMittContextSelector<T>(selector: (value: ContextValueType) => T): T {
return useContextSelector(MittContext, selector)
}