diff --git a/web/app/components/plugins/card/index.spec.tsx b/web/app/components/plugins/card/index.spec.tsx index fc100a08e0..8c51ef666b 100644 --- a/web/app/components/plugins/card/index.spec.tsx +++ b/web/app/components/plugins/card/index.spec.tsx @@ -998,58 +998,59 @@ describe('Icon', () => { render() expect(screen.getByTestId('app-icon')).toBeInTheDocument() - it('should not render status indicators when src is object with installed=true', () => { - render() + }) - // Status indicators should not render for object src - expect(screen.queryByTestId('ri-check-line')).not.toBeInTheDocument() - }) + it('should not render status indicators when src is object with installed=true', () => { + render() - it('should not render status indicators when src is object with installFailed=true', () => { - render() + // Status indicators should not render for object src + expect(screen.queryByTestId('ri-check-line')).not.toBeInTheDocument() + }) - // Status indicators should not render for object src - expect(screen.queryByTestId('ri-close-line')).not.toBeInTheDocument() - }) + it('should not render status indicators when src is object with installFailed=true', () => { + render() - it('should render object src with all size variants', () => { - const sizes: Array<'xs' | 'tiny' | 'small' | 'medium' | 'large'> = ['xs', 'tiny', 'small', 'medium', 'large'] + // Status indicators should not render for object src + expect(screen.queryByTestId('ri-close-line')).not.toBeInTheDocument() + }) - sizes.forEach((size) => { - const { unmount } = render() - expect(screen.getByTestId('app-icon')).toHaveAttribute('data-size', size) - unmount() - }) - }) + it('should render object src with all size variants', () => { + const sizes: Array<'xs' | 'tiny' | 'small' | 'medium' | 'large'> = ['xs', 'tiny', 'small', 'medium', 'large'] - it('should render object src with custom className', () => { - const { container } = render( - , - ) - - expect(container.querySelector('.custom-object-icon')).toBeInTheDocument() - }) - - it('should pass correct props to AppIcon for object src', () => { - render() - - const appIcon = screen.getByTestId('app-icon') - expect(appIcon).toHaveAttribute('data-icon', '😀') - expect(appIcon).toHaveAttribute('data-background', '#123456') - expect(appIcon).toHaveAttribute('data-icon-type', 'emoji') - }) - - it('should render inner icon only when shouldUseMcpIcon returns true', () => { - // Test with MCP icon content - const { unmount } = render() - expect(screen.getByTestId('inner-icon')).toBeInTheDocument() + sizes.forEach((size) => { + const { unmount } = render() + expect(screen.getByTestId('app-icon')).toHaveAttribute('data-size', size) unmount() - - // Test without MCP icon content - render() - expect(screen.queryByTestId('inner-icon')).not.toBeInTheDocument() }) }) + + it('should render object src with custom className', () => { + const { container } = render( + , + ) + + expect(container.querySelector('.custom-object-icon')).toBeInTheDocument() + }) + + it('should pass correct props to AppIcon for object src', () => { + render() + + const appIcon = screen.getByTestId('app-icon') + expect(appIcon).toHaveAttribute('data-icon', '😀') + expect(appIcon).toHaveAttribute('data-background', '#123456') + expect(appIcon).toHaveAttribute('data-icon-type', 'emoji') + }) + + it('should render inner icon only when shouldUseMcpIcon returns true', () => { + // Test with MCP icon content + const { unmount } = render() + expect(screen.getByTestId('inner-icon')).toBeInTheDocument() + unmount() + + // Test without MCP icon content + render() + expect(screen.queryByTestId('inner-icon')).not.toBeInTheDocument() + }) }) // ================================ diff --git a/web/app/components/plugins/plugin-detail-panel/app-selector/index.spec.tsx b/web/app/components/plugins/plugin-detail-panel/app-selector/index.spec.tsx index 415e7bf80c..fd66e7c45e 100644 --- a/web/app/components/plugins/plugin-detail-panel/app-selector/index.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/app-selector/index.spec.tsx @@ -2,6 +2,7 @@ import type { ReactNode } from 'react' import type { App } from '@/types/app' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { act, fireEvent, render, screen } from '@testing-library/react' +import * as React from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { InputVarType } from '@/app/components/workflow/types' import { AppModeEnum } from '@/types/app' @@ -9,6 +10,7 @@ import AppInputsForm from './app-inputs-form' import AppInputsPanel from './app-inputs-panel' import AppPicker from './app-picker' import AppTrigger from './app-trigger' + import AppSelector from './index' // ==================== Mock Setup ==================== @@ -73,44 +75,59 @@ afterAll(() => { }) // Mock portal components for controlled positioning in tests -let mockPortalOpenState = false +// Use React context to properly scope open state per portal instance (for nested portals) +const _PortalOpenContext = React.createContext(false) -vi.mock('@/app/components/base/portal-to-follow-elem', () => ({ - PortalToFollowElem: ({ - children, - open, - }: { - children: ReactNode - open?: boolean - }) => { - mockPortalOpenState = open || false - return ( -
+vi.mock('@/app/components/base/portal-to-follow-elem', () => { + // Context reference shared across mock components + let sharedContext: React.Context | null = null + + // Lazily get or create the context + const getContext = (): React.Context => { + if (!sharedContext) + sharedContext = React.createContext(false) + return sharedContext + } + + return { + PortalToFollowElem: ({ + children, + open, + }: { + children: ReactNode + open?: boolean + }) => { + const Context = getContext() + return React.createElement( + Context.Provider, + { value: open || false }, + React.createElement('div', { 'data-testid': 'portal-to-follow-elem', 'data-open': open }, children), + ) + }, + PortalToFollowElemTrigger: ({ + children, + onClick, + className, + }: { + children: ReactNode + onClick?: () => void + className?: string + }) => ( +
{children}
- ) - }, - PortalToFollowElemTrigger: ({ - children, - onClick, - className, - }: { - children: ReactNode - onClick?: () => void - className?: string - }) => ( -
- {children} -
- ), - PortalToFollowElemContent: ({ children, className }: { children: ReactNode, className?: string }) => { - if (!mockPortalOpenState) - return null - return ( -
{children}
- ) - }, -})) + ), + PortalToFollowElemContent: ({ children, className }: { children: ReactNode, className?: string }) => { + const Context = getContext() + const isOpen = React.useContext(Context) + if (!isOpen) + return null + return ( +
{children}
+ ) + }, + } +}) // Mock service hooks let mockAppListData: { pages: Array<{ data: App[], has_more: boolean, page: number }> } | undefined @@ -129,10 +146,12 @@ const getAppDetailData = (appId: string) => { return mockAppDetailData if (!appId) return undefined + // Extract number from appId (e.g., 'app-1' -> '1') for consistent naming with createMockApps + const appNumber = appId.replace('app-', '') // Return a basic mock app structure return { id: appId, - name: `App ${appId}`, + name: `App ${appNumber}`, mode: 'chat', icon_type: 'emoji', icon: '🤖', @@ -345,20 +364,25 @@ const createMockApp = (overrides: Record = {}): App => ({ ...overrides, } as unknown as App) +// Helper function to get app mode based on index +const getAppModeByIndex = (index: number): AppModeEnum => { + if (index % 5 === 0) + return AppModeEnum.ADVANCED_CHAT + if (index % 4 === 0) + return AppModeEnum.AGENT_CHAT + if (index % 3 === 0) + return AppModeEnum.WORKFLOW + if (index % 2 === 0) + return AppModeEnum.COMPLETION + return AppModeEnum.CHAT +} + const createMockApps = (count: number): App[] => { return Array.from({ length: count }, (_, i) => createMockApp({ id: `app-${i + 1}`, name: `App ${i + 1}`, - mode: i % 5 === 0 - ? AppModeEnum.ADVANCED_CHAT - : i % 4 === 0 - ? AppModeEnum.AGENT_CHAT - : i % 3 === 0 - ? AppModeEnum.WORKFLOW - : i % 2 === 0 - ? AppModeEnum.COMPLETION - : AppModeEnum.CHAT, + mode: getAppModeByIndex(i), })) } @@ -446,7 +470,6 @@ describe('AppPicker', () => { beforeEach(() => { vi.clearAllMocks() vi.useFakeTimers() - mockPortalOpenState = false }) afterEach(() => { @@ -460,14 +483,12 @@ describe('AppPicker', () => { }) it('should render app list when open', () => { - mockPortalOpenState = true render() expect(screen.getByText('App 1')).toBeInTheDocument() expect(screen.getByText('App 2')).toBeInTheDocument() }) it('should show loading indicator when isLoading is true', () => { - mockPortalOpenState = true render() expect(screen.getByText('common.loading')).toBeInTheDocument() }) @@ -480,7 +501,6 @@ describe('AppPicker', () => { describe('User Interactions', () => { it('should call onSelect when app is clicked', () => { - mockPortalOpenState = true const onSelect = vi.fn() render() @@ -489,7 +509,6 @@ describe('AppPicker', () => { }) it('should call onSearchChange when typing in search input', () => { - mockPortalOpenState = true const onSearchChange = vi.fn() render() @@ -517,35 +536,30 @@ describe('AppPicker', () => { describe('App Type Display', () => { it('should display correct app type for CHAT', () => { - mockPortalOpenState = true const apps = [createMockApp({ id: 'chat-app', name: 'Chat App', mode: AppModeEnum.CHAT })] render() expect(screen.getByText('chat')).toBeInTheDocument() }) it('should display correct app type for WORKFLOW', () => { - mockPortalOpenState = true const apps = [createMockApp({ id: 'workflow-app', name: 'Workflow App', mode: AppModeEnum.WORKFLOW })] render() expect(screen.getByText('workflow')).toBeInTheDocument() }) it('should display correct app type for ADVANCED_CHAT', () => { - mockPortalOpenState = true const apps = [createMockApp({ id: 'chatflow-app', name: 'Chatflow App', mode: AppModeEnum.ADVANCED_CHAT })] render() expect(screen.getByText('chatflow')).toBeInTheDocument() }) it('should display correct app type for AGENT_CHAT', () => { - mockPortalOpenState = true const apps = [createMockApp({ id: 'agent-app', name: 'Agent App', mode: AppModeEnum.AGENT_CHAT })] render() expect(screen.getByText('agent')).toBeInTheDocument() }) it('should display correct app type for COMPLETION', () => { - mockPortalOpenState = true const apps = [createMockApp({ id: 'completion-app', name: 'Completion App', mode: AppModeEnum.COMPLETION })] render() expect(screen.getByText('completion')).toBeInTheDocument() @@ -554,13 +568,11 @@ describe('AppPicker', () => { describe('Edge Cases', () => { it('should handle empty apps array', () => { - mockPortalOpenState = true render() expect(screen.queryByRole('listitem')).not.toBeInTheDocument() }) it('should handle search text with value', () => { - mockPortalOpenState = true render() const input = screen.getByTestId('input') expect(input).toHaveValue('test search') @@ -569,7 +581,6 @@ describe('AppPicker', () => { describe('Search Clear', () => { it('should call onSearchChange with empty string when clear button is clicked', () => { - mockPortalOpenState = true const onSearchChange = vi.fn() render() @@ -581,7 +592,6 @@ describe('AppPicker', () => { describe('Infinite Scroll', () => { it('should not call onLoadMore when isLoading is true', () => { - mockPortalOpenState = true const onLoadMore = vi.fn() render() @@ -594,7 +604,6 @@ describe('AppPicker', () => { }) it('should not call onLoadMore when hasMore is false', () => { - mockPortalOpenState = true const onLoadMore = vi.fn() render() @@ -607,7 +616,6 @@ describe('AppPicker', () => { }) it('should call onLoadMore when intersection observer fires and conditions are met', () => { - mockPortalOpenState = true const onLoadMore = vi.fn() render() @@ -619,7 +627,6 @@ describe('AppPicker', () => { }) it('should not call onLoadMore when target is not intersecting', () => { - mockPortalOpenState = true const onLoadMore = vi.fn() render() @@ -631,7 +638,6 @@ describe('AppPicker', () => { }) it('should handle observer target ref', () => { - mockPortalOpenState = true render() // The component should render without errors @@ -642,11 +648,9 @@ describe('AppPicker', () => { const { rerender } = render() // Change isShow to true - mockPortalOpenState = true rerender() // Then back to false - mockPortalOpenState = false rerender() // Should not crash @@ -654,8 +658,6 @@ describe('AppPicker', () => { }) it('should setup intersection observer when isShow is true', () => { - mockPortalOpenState = true - render() // IntersectionObserver callback should have been set @@ -663,14 +665,12 @@ describe('AppPicker', () => { }) it('should disconnect observer when isShow changes from true to false', () => { - mockPortalOpenState = true const { rerender } = render() // Verify observer was set up expect(intersectionObserverCallback).not.toBeNull() // Change to not shown - should disconnect observer (lines 74-75) - mockPortalOpenState = false rerender() // Component should render without errors @@ -678,7 +678,6 @@ describe('AppPicker', () => { }) it('should cleanup observer on component unmount', () => { - mockPortalOpenState = true const { unmount } = render() // Unmount should trigger cleanup without throwing @@ -686,8 +685,6 @@ describe('AppPicker', () => { }) it('should handle MutationObserver callback when target becomes available', () => { - mockPortalOpenState = true - render() // Trigger MutationObserver callback (simulates DOM change) @@ -699,8 +696,6 @@ describe('AppPicker', () => { it('should not setup IntersectionObserver when observerTarget is null', () => { // When isShow is false, the observer target won't be in the DOM - mockPortalOpenState = false - render() // The guard at line 84 should prevent setup @@ -708,7 +703,6 @@ describe('AppPicker', () => { }) it('should debounce onLoadMore calls using loadingRef', () => { - mockPortalOpenState = true const onLoadMore = vi.fn() render() @@ -1430,7 +1424,6 @@ describe('AppSelector', () => { beforeEach(() => { vi.clearAllMocks() vi.useFakeTimers() - mockPortalOpenState = false mockAppListData = { pages: [{ data: createMockApps(5), has_more: false, page: 1 }], } @@ -2095,7 +2088,6 @@ describe('AppSelector Integration', () => { beforeEach(() => { vi.clearAllMocks() vi.useFakeTimers() - mockPortalOpenState = false mockAppListData = { pages: [{ data: createMockApps(5), has_more: false, page: 1 }], } diff --git a/web/app/components/plugins/plugin-detail-panel/app-selector/index.tsx b/web/app/components/plugins/plugin-detail-panel/app-selector/index.tsx index 40b0ba9205..5d0fa6d4b8 100644 --- a/web/app/components/plugins/plugin-detail-panel/app-selector/index.tsx +++ b/web/app/components/plugins/plugin-detail-panel/app-selector/index.tsx @@ -23,8 +23,8 @@ const PAGE_SIZE = 20 type Props = { value?: { app_id: string - inputs: Record - files?: any[] + inputs: Record + files?: unknown[] } scope?: string disabled?: boolean @@ -32,8 +32,8 @@ type Props = { offset?: OffsetOptions onSelect: (app: { app_id: string - inputs: Record - files?: any[] + inputs: Record + files?: unknown[] }) => void supportAddCustomTool?: boolean } @@ -63,12 +63,12 @@ const AppSelector: FC = ({ name: searchText, }) - const pages = data?.pages ?? [] const displayedApps = useMemo(() => { + const pages = data?.pages ?? [] if (!pages.length) return [] return pages.flatMap(({ data: apps }) => apps) - }, [pages]) + }, [data?.pages]) // fetch selected app by id to avoid pagination gaps const { data: selectedAppDetail } = useAppDetail(value?.app_id || '') @@ -130,7 +130,7 @@ const AppSelector: FC = ({ setIsShowChooseApp(false) } - const handleFormChange = (inputs: Record) => { + const handleFormChange = (inputs: Record) => { const newFiles = inputs['#image#'] delete inputs['#image#'] const newValue = { diff --git a/web/app/components/plugins/plugin-detail-panel/tool-selector/components/reasoning-config-form.tsx b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/reasoning-config-form.tsx index 0cee3a2111..6ffb8756d3 100644 --- a/web/app/components/plugins/plugin-detail-panel/tool-selector/components/reasoning-config-form.tsx +++ b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/reasoning-config-form.tsx @@ -6,6 +6,7 @@ import type { ToolVarInputs } from '@/app/components/workflow/nodes/tool/types' import type { NodeOutPutVar, ValueSelector, + Var, } from '@/app/components/workflow/types' import { RiArrowRightUpLine, @@ -34,9 +35,21 @@ import { VarType } from '@/app/components/workflow/types' import { cn } from '@/utils/classnames' import SchemaModal from './schema-modal' +type ReasoningConfigInputValue = { + type?: VarKindType + value?: unknown +} | null + +type ReasoningConfigInput = { + value: ReasoningConfigInputValue + auto?: 0 | 1 +} + +export type ReasoningConfigValue = Record + type Props = { - value: Record - onChange: (val: Record) => void + value: ReasoningConfigValue + onChange: (val: ReasoningConfigValue) => void schemas: ToolFormSchema[] nodeOutputVars: NodeOutPutVar[] availableNodes: Node[] @@ -62,7 +75,7 @@ const ReasoningConfigForm: React.FC = ({ return VarKindType.mixed } - const handleAutomatic = (key: string, val: any, type: string) => { + const handleAutomatic = (key: string, val: boolean, type: string) => { onChange({ ...value, [key]: { @@ -71,7 +84,7 @@ const ReasoningConfigForm: React.FC = ({ }, }) } - const handleTypeChange = useCallback((variable: string, defaultValue: any) => { + const handleTypeChange = useCallback((variable: string, defaultValue: unknown) => { return (newType: VarKindType) => { const res = produce(value, (draft: ToolVarInputs) => { draft[variable].value = { @@ -83,7 +96,7 @@ const ReasoningConfigForm: React.FC = ({ } }, [onChange, value]) const handleValueChange = useCallback((variable: string, varType: string) => { - return (newValue: any) => { + return (newValue: unknown) => { const res = produce(value, (draft: ToolVarInputs) => { draft[variable].value = { type: getVarKindType(varType), @@ -96,22 +109,23 @@ const ReasoningConfigForm: React.FC = ({ const handleAppChange = useCallback((variable: string) => { return (app: { app_id: string - inputs: Record - files?: any[] + inputs: Record + files?: unknown[] }) => { const newValue = produce(value, (draft: ToolVarInputs) => { - draft[variable].value = app as any + draft[variable].value = app }) onChange(newValue) } }, [onChange, value]) const handleModelChange = useCallback((variable: string) => { - return (model: any) => { + return (model: Record) => { const newValue = produce(value, (draft: ToolVarInputs) => { + const currentValue = draft[variable].value as Record | undefined draft[variable].value = { - ...draft[variable].value, + ...currentValue, ...model, - } as any + } }) onChange(newValue) } @@ -196,17 +210,17 @@ const ReasoningConfigForm: React.FC = ({ } const getFilterVar = () => { if (isNumber) - return (varPayload: any) => varPayload.type === VarType.number + return (varPayload: Var) => varPayload.type === VarType.number else if (isString) - return (varPayload: any) => [VarType.string, VarType.number, VarType.secret].includes(varPayload.type) + return (varPayload: Var) => [VarType.string, VarType.number, VarType.secret].includes(varPayload.type) else if (isFile) - return (varPayload: any) => [VarType.file, VarType.arrayFile].includes(varPayload.type) + return (varPayload: Var) => [VarType.file, VarType.arrayFile].includes(varPayload.type) else if (isBoolean) - return (varPayload: any) => varPayload.type === VarType.boolean + return (varPayload: Var) => varPayload.type === VarType.boolean else if (isObject) - return (varPayload: any) => varPayload.type === VarType.object + return (varPayload: Var) => varPayload.type === VarType.object else if (isArray) - return (varPayload: any) => [VarType.array, VarType.arrayString, VarType.arrayNumber, VarType.arrayObject].includes(varPayload.type) + return (varPayload: Var) => [VarType.array, VarType.arrayString, VarType.arrayNumber, VarType.arrayObject].includes(varPayload.type) return undefined } @@ -266,7 +280,7 @@ const ReasoningConfigForm: React.FC = ({ handleValueChange(variable, type)(e.target.value)} placeholder={placeholder?.[language] || placeholder?.en_US} /> @@ -280,10 +294,10 @@ const ReasoningConfigForm: React.FC = ({ {isSelect && options && ( { if (option.show_on.length) - return option.show_on.every(showOnItem => value[showOnItem.variable] === showOnItem.value) + return option.show_on.every(showOnItem => value[showOnItem.variable]?.value?.value === showOnItem.value) return true }).map(option => ({ value: option.value, name: option.label[language] || option.label.en_US }))} @@ -295,7 +309,7 @@ const ReasoningConfigForm: React.FC = ({
= ({ , files?: unknown[] } | undefined} onSelect={handleAppChange(variable)} /> )} @@ -331,7 +345,7 @@ const ReasoningConfigForm: React.FC = ({ readonly={false} isShowNodeName nodeId={nodeId} - value={varInput?.value || []} + value={(varInput?.value as string | ValueSelector) || []} onChange={handleVariableSelectorChange(variable)} filterVar={getFilterVar()} schema={schema as Partial} diff --git a/web/app/components/plugins/plugin-detail-panel/tool-selector/components/tool-credentials-form.tsx b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/tool-credentials-form.tsx index 5277cebae7..0207f65336 100644 --- a/web/app/components/plugins/plugin-detail-panel/tool-selector/components/tool-credentials-form.tsx +++ b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/tool-credentials-form.tsx @@ -1,6 +1,7 @@ 'use client' import type { FC } from 'react' import type { Collection } from '@/app/components/tools/types' +import type { ToolCredentialFormSchema } from '@/app/components/tools/utils/to-form-schema' import { RiArrowRightUpLine, } from '@remixicon/react' @@ -19,7 +20,7 @@ import { cn } from '@/utils/classnames' type Props = { collection: Collection onCancel: () => void - onSaved: (value: Record) => void + onSaved: (value: Record) => void } const ToolCredentialForm: FC = ({ @@ -29,9 +30,9 @@ const ToolCredentialForm: FC = ({ }) => { const getValueFromI18nObject = useRenderI18nObject() const { t } = useTranslation() - const [credentialSchema, setCredentialSchema] = useState(null) + const [credentialSchema, setCredentialSchema] = useState(null) const { name: collectionName } = collection - const [tempCredential, setTempCredential] = React.useState({}) + const [tempCredential, setTempCredential] = React.useState>({}) useEffect(() => { fetchBuiltInToolCredentialSchema(collectionName).then(async (res) => { const toolCredentialSchemas = toolCredentialToFormSchemas(res) @@ -44,6 +45,8 @@ const ToolCredentialForm: FC = ({ }, []) const handleSave = () => { + if (!credentialSchema) + return for (const field of credentialSchema) { if (field.required && !tempCredential[field.name]) { Toast.notify({ type: 'error', message: t('errorMsg.fieldRequired', { ns: 'common', field: getValueFromI18nObject(field.label) }) }) diff --git a/web/app/components/plugins/plugin-detail-panel/tool-selector/components/tool-item.tsx b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/tool-item.tsx index 995175c5ea..dd85bc376c 100644 --- a/web/app/components/plugins/plugin-detail-panel/tool-selector/components/tool-item.tsx +++ b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/tool-item.tsx @@ -22,7 +22,7 @@ import { SwitchPluginVersion } from '@/app/components/workflow/nodes/_base/compo import { cn } from '@/utils/classnames' type Props = { - icon?: any + icon?: string | { content?: string, background?: string } providerName?: string isMCPTool?: boolean providerShowName?: string @@ -33,7 +33,7 @@ type Props = { onDelete?: () => void noAuth?: boolean isError?: boolean - errorTip?: any + errorTip?: React.ReactNode uninstalled?: boolean installInfo?: string onInstall?: () => void diff --git a/web/app/components/plugins/plugin-detail-panel/tool-selector/components/tool-settings-panel.tsx b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/tool-settings-panel.tsx index 9144551a48..015b40d9fd 100644 --- a/web/app/components/plugins/plugin-detail-panel/tool-selector/components/tool-settings-panel.tsx +++ b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/tool-settings-panel.tsx @@ -2,9 +2,11 @@ import type { FC } from 'react' import type { Node } from 'reactflow' import type { TabType } from '../hooks/use-tool-selector-state' +import type { ReasoningConfigValue } from './reasoning-config-form' import type { CredentialFormSchema } from '@/app/components/header/account-setting/model-provider-page/declarations' import type { ToolFormSchema } from '@/app/components/tools/utils/to-form-schema' import type { ToolValue } from '@/app/components/workflow/block-selector/types' +import type { ToolVarInputs } from '@/app/components/workflow/nodes/tool/types' import type { NodeOutPutVar, ToolWithProvider } from '@/app/components/workflow/types' import { useTranslation } from 'react-i18next' import Divider from '@/app/components/base/divider' @@ -19,15 +21,15 @@ type ToolSettingsPanelProps = { currType: TabType settingsFormSchemas: ToolFormSchema[] paramsFormSchemas: ToolFormSchema[] - settingsValue: Record + settingsValue: ToolVarInputs showTabSlider: boolean userSettingsOnly: boolean reasoningConfigOnly: boolean nodeOutputVars: NodeOutPutVar[] availableNodes: Node[] onCurrTypeChange: (type: TabType) => void - onSettingsFormChange: (v: Record) => void - onParamsFormChange: (v: Record) => void + onSettingsFormChange: (v: ToolVarInputs) => void + onParamsFormChange: (v: ReasoningConfigValue) => void } /** @@ -140,7 +142,7 @@ const ToolSettingsPanel: FC = ({ {/* Reasoning config form */} {nodeId && (currType === 'params' || reasoningConfigOnly) && ( { const settingValues = generateFormValue( tool.params, - toolParametersToFormSchemas(tool.paramSchemas.filter(param => param.form !== 'llm') as any), + toolParametersToFormSchemas((tool.paramSchemas as ToolParameter[]).filter(param => param.form !== 'llm')), ) const paramValues = generateFormValue( tool.params, - toolParametersToFormSchemas(tool.paramSchemas.filter(param => param.form === 'llm') as any), + toolParametersToFormSchemas((tool.paramSchemas as ToolParameter[]).filter(param => param.form === 'llm')), true, ) return { @@ -152,7 +155,7 @@ export const useToolSelectorState = ({ }) }, [value, onSelect]) - const handleSettingsFormChange = useCallback((v: Record) => { + const handleSettingsFormChange = useCallback((v: ResourceVarInputs) => { if (!value) return const newValue = getStructureValue(v) @@ -162,7 +165,7 @@ export const useToolSelectorState = ({ }) }, [value, onSelect]) - const handleParamsFormChange = useCallback((v: Record) => { + const handleParamsFormChange = useCallback((v: ReasoningConfigValue) => { if (!value) return onSelect({ @@ -204,8 +207,8 @@ export const useToolSelectorState = ({ } }, [invalidateAllBuiltinTools, invalidateInstalledPluginList]) - const getSettingsValue = useCallback(() => { - return getPlainValue(value?.settings || {}) + const getSettingsValue = useCallback((): ResourceVarInputs => { + return getPlainValue((value?.settings || {}) as Record) as ResourceVarInputs }, [value?.settings]) return { diff --git a/web/app/components/plugins/plugin-detail-panel/tool-selector/index.spec.tsx b/web/app/components/plugins/plugin-detail-panel/tool-selector/index.spec.tsx index 96ca1ed56d..f4ed1bcae5 100644 --- a/web/app/components/plugins/plugin-detail-panel/tool-selector/index.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/tool-selector/index.spec.tsx @@ -8,6 +8,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { act, fireEvent, render, renderHook, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { CollectionType } from '@/app/components/tools/types' +import { VarKindType } from '@/app/components/workflow/nodes/_base/types' import { Type } from '@/app/components/workflow/nodes/llm/types' import { SchemaModal, @@ -556,7 +557,7 @@ describe('useToolSelectorState Hook', () => { ) act(() => { - result.current.handleSettingsFormChange({ key: 'value' }) + result.current.handleSettingsFormChange({ key: { type: VarKindType.constant, value: 'value' } }) }) expect(onSelect).toHaveBeenCalledWith( @@ -575,11 +576,11 @@ describe('useToolSelectorState Hook', () => { ) act(() => { - result.current.handleParamsFormChange({ param: 'value' }) + result.current.handleParamsFormChange({ param: { value: { type: VarKindType.constant, value: 'value' } } }) }) expect(onSelect).toHaveBeenCalledWith( - expect.objectContaining({ parameters: { param: 'value' } }), + expect.objectContaining({ parameters: { param: { value: { type: VarKindType.constant, value: 'value' } } } }), ) }) diff --git a/web/app/components/tools/utils/to-form-schema.ts b/web/app/components/tools/utils/to-form-schema.ts index ab8363dc9f..4171590375 100644 --- a/web/app/components/tools/utils/to-form-schema.ts +++ b/web/app/components/tools/utils/to-form-schema.ts @@ -5,6 +5,35 @@ import type { SchemaRoot } from '@/app/components/workflow/nodes/llm/types' import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' import { VarType as VarKindType } from '@/app/components/workflow/nodes/tool/types' +// Type for form value input with type and value properties +type FormValueInput = { + type?: string + value?: unknown +} + +/** + * Form schema type for tool credentials. + * This type represents the schema returned by toolCredentialToFormSchemas. + */ +export type ToolCredentialFormSchema = { + name: string + variable: string + label: TypeWithI18N + type: string + required: boolean + default?: string + tooltip?: TypeWithI18N + placeholder?: TypeWithI18N + show_on: { variable: string, value: string }[] + options?: { + label: TypeWithI18N + value: string + show_on: { variable: string, value: string }[] + }[] + help?: TypeWithI18N | null + url?: string +} + /** * Form schema type for tool parameters. * This type represents the schema returned by toolParametersToFormSchemas. @@ -86,17 +115,17 @@ export const toolParametersToFormSchemas = (parameters: ToolParameter[]): ToolFo return formSchemas } -export const toolCredentialToFormSchemas = (parameters: ToolCredential[]) => { +export const toolCredentialToFormSchemas = (parameters: ToolCredential[]): ToolCredentialFormSchema[] => { if (!parameters) return [] - const formSchemas = parameters.map((parameter) => { + const formSchemas = parameters.map((parameter): ToolCredentialFormSchema => { return { ...parameter, variable: parameter.name, type: toType(parameter.type), label: parameter.label, - tooltip: parameter.help, + tooltip: parameter.help ?? undefined, show_on: [], options: parameter.options?.map((option) => { return { @@ -109,7 +138,7 @@ export const toolCredentialToFormSchemas = (parameters: ToolCredential[]) => { return formSchemas } -export const addDefaultValue = (value: Record, formSchemas: { variable: string, type: string, default?: any }[]) => { +export const addDefaultValue = (value: Record, formSchemas: { variable: string, type: string, default?: unknown }[]) => { const newValues = { ...value } formSchemas.forEach((formSchema) => { const itemValue = value[formSchema.variable] @@ -129,7 +158,7 @@ export const addDefaultValue = (value: Record, formSchemas: { varia return newValues } -const correctInitialData = (type: string, target: any, defaultValue: any) => { +const correctInitialData = (type: string, target: FormValueInput, defaultValue: unknown): FormValueInput => { if (type === 'text-input' || type === 'secret-input') target.type = 'mixed' @@ -155,39 +184,39 @@ const correctInitialData = (type: string, target: any, defaultValue: any) => { return target } -export const generateFormValue = (value: Record, formSchemas: { variable: string, default?: any, type: string }[], isReasoning = false) => { - const newValues = {} as any +export const generateFormValue = (value: Record, formSchemas: { variable: string, default?: unknown, type: string }[], isReasoning = false) => { + const newValues: Record = {} formSchemas.forEach((formSchema) => { const itemValue = value[formSchema.variable] if ((formSchema.default !== undefined) && (value === undefined || itemValue === null || itemValue === '' || itemValue === undefined)) { - const value = formSchema.default - newValues[formSchema.variable] = { - value: { - type: 'constant', - value: formSchema.default, - }, - ...(isReasoning ? { auto: 1, value: null } : {}), + const defaultVal = formSchema.default + if (isReasoning) { + newValues[formSchema.variable] = { auto: 1, value: null } + } + else { + const initialValue: FormValueInput = { type: 'constant', value: formSchema.default } + newValues[formSchema.variable] = { + value: correctInitialData(formSchema.type, initialValue, defaultVal), + } } - if (!isReasoning) - newValues[formSchema.variable].value = correctInitialData(formSchema.type, newValues[formSchema.variable].value, value) } }) return newValues } -export const getPlainValue = (value: Record) => { - const plainValue = { ...value } - Object.keys(plainValue).forEach((key) => { +export const getPlainValue = (value: Record) => { + const plainValue: Record = {} + Object.keys(value).forEach((key) => { plainValue[key] = { - ...value[key].value, + ...(value[key].value as object), } }) return plainValue } -export const getStructureValue = (value: Record) => { - const newValue = { ...value } as any - Object.keys(newValue).forEach((key) => { +export const getStructureValue = (value: Record): Record => { + const newValue: Record = {} + Object.keys(value).forEach((key) => { newValue[key] = { value: value[key], } @@ -195,17 +224,17 @@ export const getStructureValue = (value: Record) => { return newValue } -export const getConfiguredValue = (value: Record, formSchemas: { variable: string, type: string, default?: any }[]) => { - const newValues = { ...value } +export const getConfiguredValue = (value: Record, formSchemas: { variable: string, type: string, default?: unknown }[]) => { + const newValues: Record = { ...value } formSchemas.forEach((formSchema) => { const itemValue = value[formSchema.variable] if ((formSchema.default !== undefined) && (value === undefined || itemValue === null || itemValue === '' || itemValue === undefined)) { - const value = formSchema.default - newValues[formSchema.variable] = { + const defaultVal = formSchema.default + const initialValue: FormValueInput = { type: 'constant', value: typeof formSchema.default === 'string' ? formSchema.default.replace(/\n/g, '\\n') : formSchema.default, } - newValues[formSchema.variable] = correctInitialData(formSchema.type, newValues[formSchema.variable], value) + newValues[formSchema.variable] = correctInitialData(formSchema.type, initialValue, defaultVal) } }) return newValues @@ -220,24 +249,24 @@ const getVarKindType = (type: FormTypeEnum) => { return VarKindType.mixed } -export const generateAgentToolValue = (value: Record, formSchemas: { variable: string, default?: any, type: string }[], isReasoning = false) => { - const newValues = {} as any +export const generateAgentToolValue = (value: Record, formSchemas: { variable: string, default?: unknown, type: string }[], isReasoning = false) => { + const newValues: Record = {} if (!isReasoning) { formSchemas.forEach((formSchema) => { const itemValue = value[formSchema.variable] newValues[formSchema.variable] = { value: { type: 'constant', - value: itemValue.value, + value: itemValue?.value, }, } - newValues[formSchema.variable].value = correctInitialData(formSchema.type, newValues[formSchema.variable].value, itemValue.value) + newValues[formSchema.variable].value = correctInitialData(formSchema.type, newValues[formSchema.variable].value!, itemValue?.value) }) } else { formSchemas.forEach((formSchema) => { const itemValue = value[formSchema.variable] - if (itemValue.auto === 1) { + if (itemValue?.auto === 1) { newValues[formSchema.variable] = { auto: 1, value: null, @@ -246,7 +275,7 @@ export const generateAgentToolValue = (value: Record, formSchemas: else { newValues[formSchema.variable] = { auto: 0, - value: itemValue.value || { + value: (itemValue?.value as FormValueInput) || { type: getVarKindType(formSchema.type as FormTypeEnum), value: null, }, diff --git a/web/app/components/workflow/nodes/tool/use-config.ts b/web/app/components/workflow/nodes/tool/use-config.ts index add4282a99..7e4594f4f2 100644 --- a/web/app/components/workflow/nodes/tool/use-config.ts +++ b/web/app/components/workflow/nodes/tool/use-config.ts @@ -174,7 +174,7 @@ const useConfig = (id: string, payload: ToolNodeType) => { draft.tool_configurations = getConfiguredValue( tool_configurations, toolSettingSchema, - ) + ) as ToolVarInputs } if ( !draft.tool_parameters @@ -183,7 +183,7 @@ const useConfig = (id: string, payload: ToolNodeType) => { draft.tool_parameters = getConfiguredValue( tool_parameters, toolInputVarSchema, - ) + ) as ToolVarInputs } }) return inputsWithDefaultValue diff --git a/web/eslint-suppressions.json b/web/eslint-suppressions.json index b043a2d951..39ea10b290 100644 --- a/web/eslint-suppressions.json +++ b/web/eslint-suppressions.json @@ -2580,11 +2580,6 @@ "count": 8 } }, - "app/components/plugins/plugin-detail-panel/app-selector/index.tsx": { - "ts/no-explicit-any": { - "count": 5 - } - }, "app/components/plugins/plugin-detail-panel/datasource-action-list.tsx": { "ts/no-explicit-any": { "count": 1 @@ -2671,26 +2666,6 @@ "count": 2 } }, - "app/components/plugins/plugin-detail-panel/tool-selector/index.tsx": { - "ts/no-explicit-any": { - "count": 15 - } - }, - "app/components/plugins/plugin-detail-panel/tool-selector/reasoning-config-form.tsx": { - "ts/no-explicit-any": { - "count": 24 - } - }, - "app/components/plugins/plugin-detail-panel/tool-selector/tool-credentials-form.tsx": { - "ts/no-explicit-any": { - "count": 3 - } - }, - "app/components/plugins/plugin-detail-panel/tool-selector/tool-item.tsx": { - "ts/no-explicit-any": { - "count": 2 - } - }, "app/components/plugins/plugin-detail-panel/trigger/event-detail-drawer.tsx": { "ts/no-explicit-any": { "count": 5 @@ -3085,11 +3060,6 @@ "count": 4 } }, - "app/components/tools/utils/to-form-schema.ts": { - "ts/no-explicit-any": { - "count": 15 - } - }, "app/components/tools/workflow-tool/configure-button.tsx": { "react-hooks/preserve-manual-memoization": { "count": 2 @@ -4919,11 +4889,6 @@ "count": 4 } }, - "service/tools.ts": { - "ts/no-explicit-any": { - "count": 2 - } - }, "service/use-apps.ts": { "ts/no-explicit-any": { "count": 1 diff --git a/web/service/tools.ts b/web/service/tools.ts index 99b84d3981..7ffe8ef65a 100644 --- a/web/service/tools.ts +++ b/web/service/tools.ts @@ -1,5 +1,6 @@ import type { Collection, + Credential, CustomCollectionBackend, CustomParamSchema, Tool, @@ -41,9 +42,9 @@ export const fetchBuiltInToolCredentialSchema = (collectionName: string) => { } export const fetchBuiltInToolCredential = (collectionName: string) => { - return get(`/workspaces/current/tool-provider/builtin/${collectionName}/credentials`) + return get>(`/workspaces/current/tool-provider/builtin/${collectionName}/credentials`) } -export const updateBuiltInToolCredential = (collectionName: string, credential: Record) => { +export const updateBuiltInToolCredential = (collectionName: string, credential: Record) => { return post(`/workspaces/current/tool-provider/builtin/${collectionName}/update`, { body: { credentials: credential, @@ -102,7 +103,14 @@ export const importSchemaFromURL = (url: string) => { }) } -export const testAPIAvailable = (payload: any) => { +export const testAPIAvailable = (payload: { + provider_name: string + tool_name: string + credentials: Credential + schema_type: string + schema: string + parameters: Record +}) => { return post('/workspaces/current/tool-provider/api/test/pre', { body: { ...payload,