diff --git a/dify-agent-runtime/README.md b/dify-agent-runtime/README.md index 9e08532a5b5..49ecc5890bf 100644 --- a/dify-agent-runtime/README.md +++ b/dify-agent-runtime/README.md @@ -78,12 +78,12 @@ The runner automatically creates `$CWD/.tmp` and sets `TMPDIR`, `TMP`, `TEMP` to ### Environment Variables -| Variable | Default | Description | -|----------|---------|-------------| -| `SHELLCTL_ENABLE_PATH_ISOLATION` | `true` | Set to `false` to disable Landlock entirely | -| `SHELLCTL_LANDLOCK_RW_PATHS` | *(empty)* | Comma-separated RW directories (besides `$HOME`) | -| `SHELLCTL_LANDLOCK_RO_PATHS` | `/usr,/bin,...` | Comma-separated RO+exec directories | -| `SHELLCTL_LANDLOCK_RW_DEV_PATHS` | `/dev/null,...` | Comma-separated device files with RW access | +| Variable | Default | Description | +| -------------------------------- | --------------- | ------------------------------------------------ | +| `SHELLCTL_ENABLE_PATH_ISOLATION` | `true` | Set to `false` to disable Landlock entirely | +| `SHELLCTL_LANDLOCK_RW_PATHS` | _(empty)_ | Comma-separated RW directories (besides `$HOME`) | +| `SHELLCTL_LANDLOCK_RO_PATHS` | `/usr,/bin,...` | Comma-separated RO+exec directories | +| `SHELLCTL_LANDLOCK_RW_DEV_PATHS` | `/dev/null,...` | Comma-separated device files with RW access | Requires Linux ≥ 5.13. On unsupported kernels, a warning is printed to stderr. diff --git a/web/app/components/plugins/plugin-auth/__tests__/authorized-in-node.spec.tsx b/web/app/components/plugins/plugin-auth/__tests__/authorized-in-node.spec.tsx index 08a9478412a..899ed6f4d6f 100644 --- a/web/app/components/plugins/plugin-auth/__tests__/authorized-in-node.spec.tsx +++ b/web/app/components/plugins/plugin-auth/__tests__/authorized-in-node.spec.tsx @@ -8,12 +8,13 @@ import { AuthCategory, CredentialTypeEnum } from '../types' // ==================== Mock Setup ==================== const mockGetPluginCredentialInfo = vi.fn() +const mockIsPluginCredentialInfoLoading = vi.fn() const mockGetPluginOAuthClientSchema = vi.fn() vi.mock('@/service/use-plugins-auth', () => ({ useGetPluginCredentialInfo: (url: string) => ({ data: url ? mockGetPluginCredentialInfo() : undefined, - isLoading: false, + isLoading: mockIsPluginCredentialInfoLoading(), }), useDeletePluginCredential: () => ({ mutateAsync: vi.fn() }), useSetPluginDefaultCredential: () => ({ mutateAsync: vi.fn() }), @@ -85,6 +86,7 @@ describe('AuthorizedInNode Component', () => { beforeEach(() => { vi.clearAllMocks() mockIsCurrentWorkspaceManager.mockReturnValue(true) + mockIsPluginCredentialInfoLoading.mockReturnValue(false) mockGetPluginCredentialInfo.mockReturnValue({ credentials: [createCredential({ is_default: true })], supported_credential_types: [CredentialTypeEnum.API_KEY], @@ -163,6 +165,25 @@ describe('AuthorizedInNode Component', () => { expect(screen.getByText('plugin.auth.authRemoved'))!.toBeInTheDocument() }) + it('should not show auth removed while credential info is loading', async () => { + const AuthorizedInNode = (await import('../authorized-in-node')).default + mockIsPluginCredentialInfoLoading.mockReturnValue(true) + mockGetPluginCredentialInfo.mockReturnValue(undefined) + const pluginPayload = createPluginPayload() + + render( + , + { wrapper: createWrapper() }, + ) + + expect(screen.queryByText('plugin.auth.authRemoved')).not.toBeInTheDocument() + expect(screen.getByText('common.loading')).toBeInTheDocument() + }) + it('should show unavailable when credential is not allowed', async () => { const AuthorizedInNode = (await import('../authorized-in-node')).default const credential = createCredential({ diff --git a/web/app/components/plugins/plugin-auth/authorized-in-node.tsx b/web/app/components/plugins/plugin-auth/authorized-in-node.tsx index ad4117c7e6e..1d996ce4b07 100644 --- a/web/app/components/plugins/plugin-auth/authorized-in-node.tsx +++ b/web/app/components/plugins/plugin-auth/authorized-in-node.tsx @@ -26,6 +26,7 @@ const AuthorizedInNode = ({ canApiKey, canOAuth, credentials, + isLoading, invalidPluginCredentialInfo, notAllowCustomCredential, } = usePluginAuth(pluginPayload, true, credentialId ? [credentialId] : undefined) @@ -53,8 +54,14 @@ const AuthorizedInNode = ({ } } else { const credential = credentials.find((c) => c.id === credentialId) - label = credential ? credential.name : t(($) => $['auth.authRemoved'], { ns: 'plugin' }) - removed = !credential + if (credential) label = credential.name + else if (isLoading) { + label = t(($) => $.loading, { ns: 'common' }) + color = 'disabled' + } else { + label = t(($) => $['auth.authRemoved'], { ns: 'plugin' }) + removed = true + } unavailable = !!credential?.not_allowed_to_use && !credential?.from_enterprise if (removed) color = 'error' @@ -86,7 +93,7 @@ const AuthorizedInNode = ({ ) }, - [credentialId, credentials, t], + [credentialId, credentials, isLoading, t], ) const defaultUnavailable = credentials.find((c) => c.is_default)?.not_allowed_to_use const extraAuthorizationItems: Credential[] = [ diff --git a/web/app/components/plugins/plugin-auth/hooks/use-plugin-auth.ts b/web/app/components/plugins/plugin-auth/hooks/use-plugin-auth.ts index e38fb021a3f..2ec1f52f0bd 100644 --- a/web/app/components/plugins/plugin-auth/hooks/use-plugin-auth.ts +++ b/web/app/components/plugins/plugin-auth/hooks/use-plugin-auth.ts @@ -10,7 +10,11 @@ export const usePluginAuth = ( enable?: boolean, includeCredentialIds?: string[], ) => { - const { data } = useGetPluginCredentialInfoHook(pluginPayload, enable, includeCredentialIds) + const { data, isLoading } = useGetPluginCredentialInfoHook( + pluginPayload, + enable, + includeCredentialIds, + ) const isAuthorized = !!data?.credentials.length const canOAuth = data?.supported_credential_types.includes(CredentialTypeEnum.OAUTH2) const canApiKey = data?.supported_credential_types.includes(CredentialTypeEnum.API_KEY) @@ -20,6 +24,7 @@ export const usePluginAuth = ( isAuthorized, canOAuth, canApiKey, + isLoading, credentials: data?.credentials || [], notAllowCustomCredential: data?.allow_custom_token === false, invalidPluginCredentialInfo, diff --git a/web/app/components/workflow/nodes/agent-v2/__tests__/hooks.spec.tsx b/web/app/components/workflow/nodes/agent-v2/__tests__/hooks.spec.tsx index 6988574d113..95f5cb78bea 100644 --- a/web/app/components/workflow/nodes/agent-v2/__tests__/hooks.spec.tsx +++ b/web/app/components/workflow/nodes/agent-v2/__tests__/hooks.spec.tsx @@ -86,6 +86,7 @@ const mockAppComposerQueryOptions = vi.hoisted(() => } } }) => number | false + retry?: number }) => { const { input } = options @@ -96,18 +97,37 @@ const mockAppComposerQueryOptions = vi.hoisted(() => : ['workflow-agent-composer', input.params.app_id, input.params.node_id], queryFn: typeof input === 'symbol' ? input : mockAppComposerQueryFn, refetchInterval: options.refetchInterval, + retry: options.retry, } }, ), ) const mockSnippetComposerQueryOptions = vi.hoisted(() => - vi.fn(({ input }: { input: symbol | { params: { snippet_id: string; node_id: string } } }) => ({ - queryKey: - typeof input === 'symbol' - ? ['snippet-agent-composer-disabled'] - : ['snippet-agent-composer', input.params.snippet_id, input.params.node_id], - queryFn: typeof input === 'symbol' ? input : mockSnippetComposerQueryFn, - })), + vi.fn( + (options: { + input: symbol | { params: { snippet_id: string; node_id: string } } + refetchInterval?: (query: { + state: { + data?: { + agent?: unknown + } + } + }) => number | false + retry?: number + }) => { + const { input } = options + + return { + queryKey: + typeof input === 'symbol' + ? ['snippet-agent-composer-disabled'] + : ['snippet-agent-composer', input.params.snippet_id, input.params.node_id], + queryFn: typeof input === 'symbol' ? input : mockSnippetComposerQueryFn, + refetchInterval: options.refetchInterval, + retry: options.retry, + } + }, + ), ) vi.mock('@langgenius/dify-ui/toast', () => ({ @@ -187,6 +207,10 @@ vi.mock('@/service/client', () => ({ describe('useWorkflowInlineAgentDetail', () => { beforeEach(() => { vi.clearAllMocks() + mockAppComposerQueryFn.mockReset() + mockAppComposerQueryFn.mockResolvedValue({ agent: { id: 'app-agent' } }) + mockSnippetComposerQueryFn.mockReset() + mockSnippetComposerQueryFn.mockResolvedValue({ agent: { id: 'snippet-agent' } }) }) it('loads inline agent detail through the snippet composer API', async () => { @@ -255,6 +279,41 @@ describe('useWorkflowInlineAgentDetail', () => { }) expect(result.current.data).toEqual({ agent: { id: 'app-agent' } }) }) + + it('retries five times and stops polling when loading the copied inline agent composer fails', async () => { + mockAppComposerQueryFn.mockRejectedValue(new Error('Agent composer was not created')) + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + retryDelay: 0, + }, + }, + }) + const { result } = renderWorkflowHook( + () => + useWorkflowInlineAgentDetail('copied-node', 'source-inline-agent', { + pollUntilReady: true, + }), + { + queryClient, + hooksStoreProps: { + configsMap: { + flowId: 'app-1', + flowType: FlowType.appFlow, + fileSettings: {} as never, + }, + }, + }, + ) + + await waitFor(() => expect(result.current.isError).toBe(true)) + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 1200)) + }) + + expect(mockAppComposerQueryFn).toHaveBeenCalledTimes(6) + }) }) describe('useCreateInlineAgentBinding', () => { diff --git a/web/app/components/workflow/nodes/agent-v2/__tests__/panel.spec.tsx b/web/app/components/workflow/nodes/agent-v2/__tests__/panel.spec.tsx index 5829a6e34af..ffd30bcc469 100644 --- a/web/app/components/workflow/nodes/agent-v2/__tests__/panel.spec.tsx +++ b/web/app/components/workflow/nodes/agent-v2/__tests__/panel.spec.tsx @@ -1023,6 +1023,37 @@ describe('agent/panel', () => { ).not.toBeInTheDocument() }) + it('shows a retry action when loading the inline agent composer fails', () => { + mockUseWorkflowInlineAgentDetail.mockReturnValue({ + data: undefined, + isError: true, + isFetching: false, + refetch: mockWorkflowInlineAgentDetailRefetch, + }) + + const { container } = render( + , + ) + + expect(container.querySelector('[aria-busy="true"]')).not.toBeInTheDocument() + expect(screen.getByRole('alert')).toHaveTextContent( + 'agentV2.roster.nodeSelector.createInlineFailed', + ) + + fireEvent.click(screen.getByRole('button', { name: 'common.operation.retry' })) + expect(mockWorkflowInlineAgentDetailRefetch).toHaveBeenCalledTimes(1) + }) + it('recovers the inline setup panel open state from the node open marker', () => { mockUseWorkflowInlineAgentDetail.mockReturnValue({ data: undefined }) diff --git a/web/app/components/workflow/nodes/agent-v2/components/agent-roster-field.tsx b/web/app/components/workflow/nodes/agent-v2/components/agent-roster-field.tsx index 69d28cafd89..7fbdc9d97d7 100644 --- a/web/app/components/workflow/nodes/agent-v2/components/agent-roster-field.tsx +++ b/web/app/components/workflow/nodes/agent-v2/components/agent-roster-field.tsx @@ -329,11 +329,13 @@ export function AgentRosterField({ agent, agentId, canOpenPanel = true, + errorMessage, isPanelOpen, isPanelCopyPending = false, isPending = false, isLoading = false, isInlineSetup = false, + isRetrying = false, panelBody, panelMode = 'detail', showPanelDetailActions = true, @@ -341,16 +343,19 @@ export function AgentRosterField({ onChange, onMakeCopy, onPanelOpenChange, + onRetry, onSaveInlineToRoster, onStartFromScratch, }: { agent?: AgentRosterDisplayData agentId?: string canOpenPanel?: boolean + errorMessage?: string isPanelOpen?: boolean isPanelCopyPending?: boolean isLoading?: boolean isInlineSetup?: boolean + isRetrying?: boolean isPending?: boolean panelBody?: ReactNode panelMode?: AgentRosterDrawerMode @@ -359,6 +364,7 @@ export function AgentRosterField({ onChange: (agent: AgentRosterNodeData) => void onMakeCopy?: () => void onPanelOpenChange?: (open: boolean) => void + onRetry?: () => void onSaveInlineToRoster?: () => void onStartFromScratch?: () => void }) { @@ -464,7 +470,30 @@ export function AgentRosterField({ - {agent ? ( + {errorMessage ? ( +
+ + + + + {errorMessage} + + {onRetry && ( + + )} +
+ ) : agent ? ( canOpenPanel ? ( <> {isInlineSetup ? ( diff --git a/web/app/components/workflow/nodes/agent-v2/hooks.ts b/web/app/components/workflow/nodes/agent-v2/hooks.ts index 605a77f6cb5..8a739f8c82d 100644 --- a/web/app/components/workflow/nodes/agent-v2/hooks.ts +++ b/web/app/components/workflow/nodes/agent-v2/hooks.ts @@ -21,6 +21,7 @@ type CreateInlineAgentBindingOptions = { } const INLINE_AGENT_CREATION_REFETCH_INTERVAL = 1000 +const INLINE_AGENT_CREATION_RETRY_COUNT = 5 export function useAgentRosterDetail(agentId?: string) { return useQuery( @@ -46,13 +47,18 @@ export function useWorkflowInlineAgentDetail( const configsMap = useHooksStore((state) => state.configsMap) const refetchUntilReady = options?.pollUntilReady ? { + retry: INLINE_AGENT_CREATION_RETRY_COUNT, refetchInterval: (query: { state: { data?: { agent?: unknown } + status: 'error' | 'pending' | 'success' } - }) => (query.state.data?.agent ? false : INLINE_AGENT_CREATION_REFETCH_INTERVAL), + }) => + query.state.status === 'error' || query.state.data?.agent + ? false + : INLINE_AGENT_CREATION_REFETCH_INTERVAL, } : {} diff --git a/web/app/components/workflow/nodes/agent-v2/panel.tsx b/web/app/components/workflow/nodes/agent-v2/panel.tsx index 6941b1125ba..d8984fb9f8d 100644 --- a/web/app/components/workflow/nodes/agent-v2/panel.tsx +++ b/web/app/components/workflow/nodes/agent-v2/panel.tsx @@ -159,7 +159,9 @@ export function AgentV2Panel({ id, data }: NodePanelProps) { ? inlineAgentBinding.agent_id : sourceInlineAgentId const isInlineAgentCreated = isInlineAgentReady && !!inlineAgent - const isInlineAgentWaitingForCreation = isInlineAgentReady && !isInlineAgentCreated + const isInlineAgentLoadError = isInlineAgentReady && inlineAgentQuery.isError + const isInlineAgentWaitingForCreation = + isInlineAgentReady && !isInlineAgentCreated && !isInlineAgentLoadError const isInlineAgentPanelOpen = (isInlineAgentCreated || isInlineAgentPending) && openInlineAgentPanelNodeId === id const { isPending: isAppCopyingFromRoster, mutate: copyFromRosterApp } = useMutation( @@ -619,14 +621,22 @@ export function AgentV2Panel({ id, data }: NodePanelProps) { />
$['roster.nodeSelector.createInlineFailed'], { ns: 'agentV2' }) + : undefined + } isInlineSetup={isInlineAgentReady || isInlineAgentPending} isLoading={isInlineAgentLoading} isPanelCopyPending={isCopyingFromRoster} isPanelOpen={isAgentPanelOpen} isPending={isAgentBindingPending} + isRetrying={isInlineAgentLoadError && inlineAgentQuery.isFetching} panelBody={ isAgentPanelOpen && displayedAgent ? ( isInlineAgentReady || isInlineAgentPending ? ( @@ -662,6 +672,7 @@ export function AgentV2Panel({ id, data }: NodePanelProps) { onChange={handleRosterChange} onMakeCopy={rosterAgentId ? handleMakeRosterCopy : undefined} onPanelOpenChange={handleAgentPanelOpenChange} + onRetry={isInlineAgentLoadError ? () => void inlineAgentQuery.refetch() : undefined} onSaveInlineToRoster={canSaveInlineToRoster ? handleSaveInlineToRosterOpen : undefined} onStartFromScratch={canStartFromScratch ? handleStartFromScratch : undefined} /> diff --git a/web/features/agent-v2/agent-detail/configure/__tests__/model-compatibility.spec.ts b/web/features/agent-v2/agent-detail/configure/__tests__/model-compatibility.spec.ts index 296fd60ad7f..25372c6224d 100644 --- a/web/features/agent-v2/agent-detail/configure/__tests__/model-compatibility.spec.ts +++ b/web/features/agent-v2/agent-detail/configure/__tests__/model-compatibility.spec.ts @@ -176,6 +176,10 @@ describe('isAgentSuggestedModel', () => { expect(isAgentSuggestedModel(provider, createModelItem('gpt-5.5'))).toBe(true) expect(isAgentSuggestedModel(provider, createModelItem('gpt-5.5-pro'))).toBe(true) + expect(isAgentSuggestedModel(provider, createModelItem('gpt-5.6'))).toBe(true) + expect(isAgentSuggestedModel(provider, createModelItem('gpt-5.6-sol'))).toBe(true) + expect(isAgentSuggestedModel(provider, createModelItem('gpt-5.6-terra'))).toBe(true) + expect(isAgentSuggestedModel(provider, createModelItem('gpt-5.6-luna'))).toBe(true) expect(isAgentSuggestedModel(provider, createModelItem('Claude Opus 4.8'))).toBe(true) expect(isAgentSuggestedModel(provider, createModelItem('opus-4.7'))).toBe(true) expect(isAgentSuggestedModel(provider, createModelItem('Claude Sonnet 4.6'))).toBe(true) diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/tools/__tests__/index.spec.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/tools/__tests__/index.spec.tsx index adb2b0ce411..f5ded02fa30 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/tools/__tests__/index.spec.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/tools/__tests__/index.spec.tsx @@ -1,4 +1,4 @@ -import type { AddOAuthButtonProps } from '@/app/components/plugins/plugin-auth/types' +import type { AddOAuthButtonProps, Credential } from '@/app/components/plugins/plugin-auth/types' import type { ToolWithProvider } from '@/app/components/workflow/types' import type { AgentSoulConfigFormState } from '@/features/agent-v2/agent-composer/form-state' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' @@ -6,6 +6,7 @@ import { act, cleanup, render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { createStore, Provider as JotaiProvider } from 'jotai' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { CredentialTypeEnum } from '@/app/components/plugins/plugin-auth/types' import { CollectionType } from '@/app/components/tools/types' import { defaultAgentSoulConfigFormState } from '@/features/agent-v2/agent-composer/form-state' import { AgentComposerProvider } from '@/features/agent-v2/agent-composer/provider' @@ -21,6 +22,13 @@ import { AgentTools } from '../index' const toolProviderState = vi.hoisted(() => ({ builtInTools: [] as ToolWithProvider[], })) +const pluginAuthState = vi.hoisted(() => ({ + canOAuth: true as boolean | undefined, + canApiKey: false as boolean | undefined, + credentials: [] as Credential[], + notAllowCustomCredential: false, + invalidPluginCredentialInfo: vi.fn(), +})) vi.mock('@/app/components/workflow/block-selector/tool-picker', () => ({ ToolPickerContent: () =>
Mock tool picker
, @@ -51,6 +59,21 @@ vi.mock('@/app/components/plugins/plugin-auth/authorize/add-oauth-button', () => }, })) +vi.mock('@/app/components/plugins/plugin-auth/hooks/use-plugin-auth', () => ({ + usePluginAuth: () => ({ + ...pluginAuthState, + isAuthorized: pluginAuthState.credentials.length > 0, + }), +})) + +vi.mock('@/hooks/use-credential-permissions', () => ({ + useCredentialPermissions: () => ({ + canUseCredential: true, + canCreateCredential: true, + canManageCredential: true, + }), +})) + vi.mock('@/app/components/header/account-setting/model-provider-page/model-modal/Form', () => ({ default: ({ formSchemas, @@ -333,6 +356,10 @@ describe('AgentTools', () => { cleanup() vi.clearAllMocks() toolProviderState.builtInTools = [] + pluginAuthState.canOAuth = true + pluginAuthState.canApiKey = false + pluginAuthState.credentials = [] + pluginAuthState.notAllowCustomCredential = false }) describe('User Interactions', () => { @@ -521,7 +548,8 @@ describe('AgentTools', () => { expect(store.get(isAgentComposerDirtyAtom)).toBe(false) }) - it('should show authorization action for reflected OAuth provider tools with unauthorized credential type', () => { + it('should open authorization actions for reflected OAuth provider tools', async () => { + const user = userEvent.setup() toolProviderState.builtInTools = [ { ...googleProvider, @@ -537,7 +565,54 @@ describe('AgentTools', () => { name: 'tools.notAuthorized', }), ).toBeInTheDocument() - expect(screen.queryByText('plugin.auth.setupOAuth')).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: 'plugin.auth.useOAuthAuth' }), + ).not.toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'tools.notAuthorized' })) + + expect( + screen.getByRole('button', { + name: 'plugin.auth.useOAuthAuth', + }), + ).toBeInTheDocument() + }) + + it('should bind an existing credential selected from the unauthorized status', async () => { + const user = userEvent.setup() + toolProviderState.builtInTools = [ + { + ...googleProvider, + allow_delete: true, + is_team_authorization: false, + team_credentials: {}, + }, + ] + pluginAuthState.credentials = [ + { + id: 'workspace-oauth', + name: 'Workspace OAuth', + provider: 'google', + credential_type: CredentialTypeEnum.OAUTH2, + is_default: true, + }, + ] + const { store } = renderAgentToolsWithStore(reflectedUnauthorizedOAuthCredentialTypeDraft) + + await user.click(screen.getByRole('button', { name: 'tools.notAuthorized' })) + + expect(store.get(agentComposerDraftAtom).tools[0]).toMatchObject({ + credentialType: 'unauthorized', + credentialVariant: 'none', + }) + + await user.click(screen.getByText('Workspace OAuth')) + + expect(store.get(agentComposerDraftAtom).tools[0]).toMatchObject({ + credentialId: 'workspace-oauth', + credentialType: 'oauth2', + credentialVariant: 'authorized', + }) }) it('should open provider tool settings with catalog icon and parameters', async () => { diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/tools/provider-tool/item.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/tools/provider-tool/item.tsx index df5c7045bbd..74b179a3efe 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/tools/provider-tool/item.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/tools/provider-tool/item.tsx @@ -17,11 +17,8 @@ import { import { StatusDot } from '@langgenius/dify-ui/status-dot' import { memo, useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' -import { AuthCategory } from '@/app/components/plugins/plugin-auth' -import AddOAuthButton from '@/app/components/plugins/plugin-auth/authorize/add-oauth-button' -import ApiKeyModal from '@/app/components/plugins/plugin-auth/authorize/api-key-modal' +import { AuthCategory, Authorized, usePluginAuth } from '@/app/components/plugins/plugin-auth' import AuthorizedInNode from '@/app/components/plugins/plugin-auth/authorized-in-node' -import { useInvalidPluginCredentialInfoHook } from '@/app/components/plugins/plugin-auth/hooks/use-credential' import { CollectionType } from '@/app/components/tools/types' import BlockIcon from '@/app/components/workflow/block-icon' import { BlockEnum } from '@/app/components/workflow/types' @@ -58,7 +55,7 @@ function UnauthorizedCredentialStatus({ ) => void }) { const { t } = useTranslation() - const [isApiKeyModalOpen, setIsApiKeyModalOpen] = useState(false) + const [isOpen, setIsOpen] = useState(false) const pluginPayload = useMemo( () => ({ provider: tool.id, @@ -67,53 +64,58 @@ function UnauthorizedCredentialStatus({ }), [tool.id, tool.providerType], ) - const invalidPluginCredentialInfo = useInvalidPluginCredentialInfoHook(pluginPayload) - const handleApiKeyModalOpen = useCallback(() => { - setIsApiKeyModalOpen(true) - }, []) - const handleApiKeyModalClose = useCallback(() => { - setIsApiKeyModalOpen(false) - }, []) + const { + canApiKey, + canOAuth, + credentials, + invalidPluginCredentialInfo, + notAllowCustomCredential, + } = usePluginAuth(pluginPayload, true) const handleCredentialUpdate = useCallback(() => { invalidPluginCredentialInfo() onCredentialChange(undefined, tool.credentialType) }, [invalidPluginCredentialInfo, onCredentialChange, tool.credentialType]) - - if (tool.credentialType === 'oauth2') { - return ( - ( - - )} - /> - ) - } - - return ( - <> - - - + ), + [t], + ) + + return ( + ) } diff --git a/web/features/agent-v2/agent-detail/configure/model-compatibility.ts b/web/features/agent-v2/agent-detail/configure/model-compatibility.ts index 373dd760b2a..f172f0ae772 100644 --- a/web/features/agent-v2/agent-detail/configure/model-compatibility.ts +++ b/web/features/agent-v2/agent-detail/configure/model-compatibility.ts @@ -64,6 +64,7 @@ const agentSuggestedModelPatterns: RegExp[] = [ // openai /^gpt[ .-]5\.5$/i, /^gpt[ .-]5\.5[ .-]pro$/i, + /^gpt[ .-]5\.6(?:[ .-](?:sol|terra|luna))?$/i, // anthropic /^(?:claude[ .-])?opus[ .-]4\.8$/i, diff --git a/web/hooks/use-import-dsl.spec.tsx b/web/hooks/use-import-dsl.spec.tsx new file mode 100644 index 00000000000..f2d9a4f52c4 --- /dev/null +++ b/web/hooks/use-import-dsl.spec.tsx @@ -0,0 +1,150 @@ +import { act, renderHook } from '@testing-library/react' +import { DSLImportMode, DSLImportStatus } from '@/models/app' +import { AppModeEnum } from '@/types/app' +import { useImportDSL } from './use-import-dsl' + +const mockPush = vi.hoisted(() => vi.fn()) +const mockImportDSL = vi.hoisted(() => vi.fn()) +const mockImportDSLConfirm = vi.hoisted(() => vi.fn()) +const mockHandleCheckPluginDependencies = vi.hoisted(() => vi.fn()) +const mockInvalidateAppList = vi.hoisted(() => vi.fn()) +const mockSetNeedRefresh = vi.hoisted(() => vi.fn()) +const mockGetRedirection = vi.hoisted(() => vi.fn()) +const mockResolveImportedAppRedirectionTarget = vi.hoisted(() => vi.fn()) +const mockAtoms = vi.hoisted(() => ({ + userProfileId: {}, + workspacePermissionKeys: {}, +})) +const toastMocks = vi.hoisted(() => ({ + error: vi.fn(), + success: vi.fn(), + warning: vi.fn(), +})) + +vi.mock('@langgenius/dify-ui/toast', () => ({ + toast: toastMocks, +})) + +vi.mock('@tanstack/react-query', () => ({ + useSuspenseQuery: () => ({ data: { rbac_enabled: false } }), +})) + +vi.mock('jotai', () => ({ + useAtomValue: (atom: object) => { + if (atom === mockAtoms.userProfileId) return 'user-1' + if (atom === mockAtoms.workspacePermissionKeys) return ['app.create_and_management'] + }, +})) + +vi.mock('@/app/components/apps/storage', () => ({ + useSetNeedRefreshAppList: () => mockSetNeedRefresh, +})) + +vi.mock('@/app/components/workflow/plugin-dependency/hooks', () => ({ + usePluginDependencies: () => ({ + handleCheckPluginDependencies: mockHandleCheckPluginDependencies, + }), +})) + +vi.mock('@/context/account-state', () => ({ + userProfileIdAtom: mockAtoms.userProfileId, +})) + +vi.mock('@/context/permission-state', () => ({ + workspacePermissionKeysAtom: mockAtoms.workspacePermissionKeys, +})) + +vi.mock('@/features/system-features/client', () => ({ + systemFeaturesQueryOptions: () => ({}), +})) + +vi.mock('@/next/navigation', () => ({ + useRouter: () => ({ push: mockPush }), +})) + +vi.mock('@/service/apps', () => ({ + importDSL: (...args: unknown[]) => mockImportDSL(...args), + importDSLConfirm: (...args: unknown[]) => mockImportDSLConfirm(...args), +})) + +vi.mock('@/service/use-apps', () => ({ + useInvalidateAppList: () => mockInvalidateAppList, +})) + +vi.mock('@/utils/app-redirection', () => ({ + getRedirection: (...args: unknown[]) => mockGetRedirection(...args), +})) + +vi.mock('@/utils/imported-app-redirection', () => ({ + resolveImportedAppRedirectionTarget: (...args: unknown[]) => + mockResolveImportedAppRedirectionTarget(...args), +})) + +describe('useImportDSL', () => { + beforeEach(() => { + vi.clearAllMocks() + mockResolveImportedAppRedirectionTarget.mockImplementation(async (target) => target) + }) + + it('should complete a confirmed import that returns warnings', async () => { + const pendingResponse = { + id: 'import-1', + status: DSLImportStatus.PENDING, + app_mode: AppModeEnum.AGENT, + imported_dsl_version: '0.2.0', + current_dsl_version: '0.1.0', + permission_keys: [], + } + const completedResponse = { + id: 'import-1', + status: DSLImportStatus.COMPLETED_WITH_WARNINGS, + app_id: 'app-1', + app_mode: AppModeEnum.AGENT, + permission_keys: ['app.acl.view_layout'], + warnings: [ + { + code: 'agent_file_omitted', + path: 'agent.omitted_assets', + message: 'Agent file was not included.', + details: {}, + }, + ], + } + const onPending = vi.fn() + const onSuccess = vi.fn() + const onFailed = vi.fn() + mockImportDSL.mockResolvedValue(pendingResponse) + mockImportDSLConfirm.mockResolvedValue(completedResponse) + + const { result } = renderHook(() => useImportDSL()) + + await act(async () => { + await result.current.handleImportDSL( + { + mode: DSLImportMode.YAML_CONTENT, + yaml_content: 'app: demo', + }, + { onPending }, + ) + }) + await act(async () => { + await result.current.handleImportDSLConfirm({ onSuccess, onFailed }) + }) + + expect(mockImportDSLConfirm).toHaveBeenCalledWith({ import_id: 'import-1' }) + expect(onSuccess).toHaveBeenCalledWith(completedResponse) + expect(onFailed).not.toHaveBeenCalled() + expect(toastMocks.warning).toHaveBeenCalledWith('app.newApp.caution', { + description: 'app.newApp.appCreateDSLWarning', + }) + expect(mockHandleCheckPluginDependencies).toHaveBeenCalledWith('app-1') + expect(mockSetNeedRefresh).toHaveBeenCalledWith('1') + expect(mockInvalidateAppList).toHaveBeenCalledTimes(1) + expect(mockResolveImportedAppRedirectionTarget).toHaveBeenCalledWith({ + id: 'app-1', + mode: AppModeEnum.AGENT, + permission_keys: ['app.acl.view_layout'], + }) + expect(mockGetRedirection).toHaveBeenCalledTimes(1) + }) +}) diff --git a/web/hooks/use-import-dsl.ts b/web/hooks/use-import-dsl.ts index a6936e55b98..1e2d659e89e 100644 --- a/web/hooks/use-import-dsl.ts +++ b/web/hooks/use-import-dsl.ts @@ -143,9 +143,22 @@ export const useImportDSL = () => { const { status, app_id, app_mode, permission_keys } = response if (!app_id) return - if (status === DSLImportStatus.COMPLETED) { + if ( + status === DSLImportStatus.COMPLETED || + status === DSLImportStatus.COMPLETED_WITH_WARNINGS + ) { onSuccess?.(response) - toast.success(t(($) => $['newApp.appCreated'], { ns: 'app' })) + const message = t( + ($) => $[status === DSLImportStatus.COMPLETED ? 'newApp.appCreated' : 'newApp.caution'], + { ns: 'app' }, + ) + const description = + status === DSLImportStatus.COMPLETED_WITH_WARNINGS + ? t(($) => $['newApp.appCreateDSLWarning'], { ns: 'app' }) + : undefined + + if (status === DSLImportStatus.COMPLETED) toast.success(message) + else toast.warning(message, { description }) await handleCheckPluginDependencies(app_id) setNeedRefresh('1') invalidateAppList() diff --git a/web/service/base.spec.ts b/web/service/base.spec.ts index e2c0f117b03..ffa6393a8e9 100644 --- a/web/service/base.spec.ts +++ b/web/service/base.spec.ts @@ -184,6 +184,59 @@ describe('handleStream', () => { expect(onCompleted).toHaveBeenCalledWith(true, 'Bad request') }) + it.each([ + { + name: 'an error event', + payload: { + event: 'error', + message: 'Stream failed', + code: 'stream_failed', + }, + }, + { + name: 'a numeric error status', + payload: { + event: 'message', + status: 500, + message: 'Internal server error', + code: 'internal_server_error', + }, + }, + ])('should handle $name through the error callbacks', async ({ payload }) => { + const onData = vi.fn() + const onCompleted = vi.fn() + const mockReader = { + read: vi + .fn() + .mockResolvedValueOnce({ + done: false, + value: new TextEncoder().encode(`data: ${JSON.stringify(payload)}\n`), + }) + .mockResolvedValueOnce({ + done: true, + value: undefined, + }), + } + const mockResponse = { + ok: true, + body: { + getReader: () => mockReader, + }, + } as unknown as Response + + handleStream(mockResponse, onData, onCompleted) + + await waitFor(() => { + expect(onData).toHaveBeenCalledWith('', false, { + conversationId: undefined, + messageId: '', + errorMessage: payload.message, + errorCode: payload.code, + }) + }) + expect(onCompleted).toHaveBeenCalledWith(true, payload.message) + }) + it('should handle malformed JSON gracefully', async () => { const onData = vi.fn() const onCompleted = vi.fn() diff --git a/web/service/base.ts b/web/service/base.ts index 2e244ef20c1..ee07341da61 100644 --- a/web/service/base.ts +++ b/web/service/base.ts @@ -328,7 +328,8 @@ export const handleStream = ( onCompleted?.(true, 'Invalid response data') return } - if (bufferObj.status === 400 || !bufferObj.event) { + const hasErrorStatus = typeof bufferObj.status === 'number' && bufferObj.status >= 400 + if (bufferObj.event === 'error' || hasErrorStatus || !bufferObj.event) { onData('', false, { conversationId: undefined, messageId: '',