From 4ae4cc3602d2ea6637c95bb452dbcb146d154d07 Mon Sep 17 00:00:00 2001 From: oleg Date: Fri, 7 Aug 2026 11:02:52 +0200 Subject: [PATCH] feat: Add self-hosted AI Assistant onboarding (#35579) Signed-off-by: Oleg Ivaniv Co-authored-by: Tuukka Kantola --- .../@n8n/api-types/src/frontend-settings.ts | 2 + packages/@n8n/api-types/src/index.ts | 6 + .../src/schemas/instance-ai.schema.ts | 60 + .../instance-ai-settings.service.test.ts | 471 ++++- .../instance-ai-verification.service.test.ts | 391 ++++ .../instance-ai-settings.service.ts | 359 +++- .../instance-ai-verification.service.ts | 251 +++ .../instance-ai/instance-ai.controller.ts | 35 + .../modules/instance-ai/instance-ai.module.ts | 2 + .../frontend/@n8n/i18n/src/locales/en.json | 93 +- .../src/app/composables/useEditorContext.ts | 6 +- .../useGlobalEntityCreation.test.ts | 1 + .../composables/useGlobalEntityCreation.ts | 9 +- .../frontend/editor-ui/src/app/router.test.ts | 1 + packages/frontend/editor-ui/src/app/router.ts | 9 +- .../features/ai/instanceAi/InstanceAiView.vue | 88 +- .../__tests__/InstanceAiView.test.ts | 106 +- .../__tests__/SettingsInstanceAiView.test.ts | 1047 ++--------- .../instanceAiSettings.store.test.ts | 42 + .../__tests__/module.descriptor.test.ts | 5 + .../components/settings/ConnectionDialog.vue | 708 +------- .../components/settings/ConnectionFields.vue | 54 - .../__tests__/ConnectionDialog.test.ts | 394 ++--- .../composables/useInstanceAiAvailability.ts | 11 +- .../useInstanceAiConfiguration.test.ts | 82 + .../composables/useInstanceAiConfiguration.ts | 29 + .../composables/useInstanceAiSetupSteps.ts | 21 - .../ai/instanceAi/instanceAi.settings.api.ts | 25 + .../ai/instanceAi/instanceAiConfiguration.ts | 42 + .../instanceAiConnection.constants.ts | 75 + .../ai/instanceAi/instanceAiSettings.store.ts | 59 +- .../ai/instanceAi/module.descriptor.ts | 1 + .../InstanceAiOnboardingIntro.test.ts | 76 + .../onboarding/InstanceAiOnboardingIntro.vue | 229 +++ .../InstanceAiOnboardingView.test.ts | 296 ++++ .../onboarding/InstanceAiOnboardingView.vue | 166 ++ .../InstanceAiOnboardingWizard.test.ts | 527 ++++++ .../onboarding/InstanceAiOnboardingWizard.vue | 1572 +++++++++++++++++ .../useInstanceAiOnboarding.test.ts | 135 ++ .../onboarding/useInstanceAiOnboarding.ts | 69 + .../views/SettingsInstanceAiView.vue | 187 +- .../components/ProjectNavigation.test.ts | 69 +- .../projects/components/ProjectNavigation.vue | 7 +- .../composables/useInstanceAiCommands.ts | 10 +- .../playwright/pages/InstanceAiPage.ts | 49 + .../testing/playwright/services/api-helper.ts | 10 + .../tests/e2e/instance-ai/fixtures.ts | 3 +- .../instance-ai-onboarding.spec.ts | 49 + 48 files changed, 5822 insertions(+), 2117 deletions(-) create mode 100644 packages/cli/src/modules/instance-ai/__tests__/instance-ai-verification.service.test.ts create mode 100644 packages/cli/src/modules/instance-ai/instance-ai-verification.service.ts delete mode 100644 packages/frontend/editor-ui/src/features/ai/instanceAi/components/settings/ConnectionFields.vue create mode 100644 packages/frontend/editor-ui/src/features/ai/instanceAi/composables/useInstanceAiConfiguration.test.ts create mode 100644 packages/frontend/editor-ui/src/features/ai/instanceAi/composables/useInstanceAiConfiguration.ts delete mode 100644 packages/frontend/editor-ui/src/features/ai/instanceAi/composables/useInstanceAiSetupSteps.ts create mode 100644 packages/frontend/editor-ui/src/features/ai/instanceAi/instanceAiConfiguration.ts create mode 100644 packages/frontend/editor-ui/src/features/ai/instanceAi/instanceAiConnection.constants.ts create mode 100644 packages/frontend/editor-ui/src/features/ai/instanceAi/onboarding/InstanceAiOnboardingIntro.test.ts create mode 100644 packages/frontend/editor-ui/src/features/ai/instanceAi/onboarding/InstanceAiOnboardingIntro.vue create mode 100644 packages/frontend/editor-ui/src/features/ai/instanceAi/onboarding/InstanceAiOnboardingView.test.ts create mode 100644 packages/frontend/editor-ui/src/features/ai/instanceAi/onboarding/InstanceAiOnboardingView.vue create mode 100644 packages/frontend/editor-ui/src/features/ai/instanceAi/onboarding/InstanceAiOnboardingWizard.test.ts create mode 100644 packages/frontend/editor-ui/src/features/ai/instanceAi/onboarding/InstanceAiOnboardingWizard.vue create mode 100644 packages/frontend/editor-ui/src/features/ai/instanceAi/onboarding/useInstanceAiOnboarding.test.ts create mode 100644 packages/frontend/editor-ui/src/features/ai/instanceAi/onboarding/useInstanceAiOnboarding.ts create mode 100644 packages/testing/playwright/tests/e2e/instance-ai/instance-ai-onboarding.spec.ts diff --git a/packages/@n8n/api-types/src/frontend-settings.ts b/packages/@n8n/api-types/src/frontend-settings.ts index 8968f5de2e6..600064db942 100644 --- a/packages/@n8n/api-types/src/frontend-settings.ts +++ b/packages/@n8n/api-types/src/frontend-settings.ts @@ -345,6 +345,8 @@ export type FrontendModuleSettings = { browserUseEnabled: boolean; proxyEnabled: boolean; cloudManaged: boolean; + /** Whether model, sandbox, and the explicit web-search decision are configured. */ + setupCompleted?: boolean; sandboxEnabled: boolean; workflowBuilderAvailable: boolean; sandboxUnavailableReason: string | null; diff --git a/packages/@n8n/api-types/src/index.ts b/packages/@n8n/api-types/src/index.ts index e08b3ecbf71..863d3f2ab0b 100644 --- a/packages/@n8n/api-types/src/index.ts +++ b/packages/@n8n/api-types/src/index.ts @@ -366,6 +366,9 @@ export { instanceAiWorkflowAttachmentSchema, InstanceAiThreadMessagesQuery, InstanceAiAdminSettingsUpdateRequest, + InstanceAiVerifyModelRequest, + InstanceAiVerifySandboxRequest, + InstanceAiVerifySearchRequest, InstanceAiUserPreferencesUpdateRequest, InstanceAiGatewayCapabilitiesDto, InstanceAiGatewayCreateCredentialDto, @@ -447,6 +450,9 @@ export type { InstanceAiThreadStatusResponse, InstanceAiConfirmResponse, InstanceAiAdminSettingsResponse, + InstanceAiEnvManagedFields, + InstanceAiVerificationFailure, + InstanceAiVerificationResponse, InstanceAiUserPreferencesResponse, InstanceAiProviderConnection, InstanceAiSandboxProvider, diff --git a/packages/@n8n/api-types/src/schemas/instance-ai.schema.ts b/packages/@n8n/api-types/src/schemas/instance-ai.schema.ts index eb7d35d3ca7..8e68d0f2f52 100644 --- a/packages/@n8n/api-types/src/schemas/instance-ai.schema.ts +++ b/packages/@n8n/api-types/src/schemas/instance-ai.schema.ts @@ -1446,6 +1446,25 @@ export const INSTANCE_AI_MODEL_CREDENTIAL_TYPES = [ export const INSTANCE_AI_SEARCH_CREDENTIAL_TYPES = ['braveSearchApi', 'searXngApi'] as const; +export interface InstanceAiEnvManagedFields { + model: { + provider: boolean; + apiKey: boolean; + baseUrl: boolean; + model: boolean; + }; + sandbox: { + provider: boolean; + serviceUrl: boolean; + apiKey: boolean; + }; + search: { + provider: boolean; + apiKey: boolean; + url: boolean; + }; +} + export interface InstanceAiAdminSettingsResponse { enabled: boolean; permissions: InstanceAiPermissions; @@ -1460,6 +1479,9 @@ export interface InstanceAiAdminSettingsResponse { modelEnvConfigured: boolean; sandboxEnvConfigured: boolean; searchEnvConfigured: boolean; + searchDisabled: boolean; + n8nSandboxServiceUrl: string | null; + envManaged: InstanceAiEnvManagedFields; localGatewayDisabled: boolean; browserUseEnabled: boolean; } @@ -1491,10 +1513,48 @@ export class InstanceAiAdminSettingsUpdateRequest extends Z.class({ sandboxConnection: instanceAiConnectionSchema.nullable().optional(), searchConnection: instanceAiConnectionSchema.nullable().optional(), modelName: z.string().trim().min(1).nullable().optional(), + searchDisabled: z.boolean().optional(), + n8nSandboxServiceUrl: z.string().url().nullable().optional(), localGatewayDisabled: z.boolean().optional(), browserUseEnabled: z.boolean().optional(), }) {} +export const instanceAiVerificationFailureSchema = z.enum([ + 'unauthorized', + 'forbidden', + 'timeout', + 'rate_limited', + 'quota_exceeded', + 'unreachable', + 'invalid_response', + 'provider_error', +]); +export type InstanceAiVerificationFailure = z.infer; + +export class InstanceAiVerifyModelRequest extends Z.class({ + connection: instanceAiConnectionSchema.optional(), + modelName: z.string().trim().min(1).optional(), +}) {} + +export class InstanceAiVerifySandboxRequest extends Z.class({ + provider: instanceAiSandboxProviderSchema.optional(), + connection: instanceAiConnectionSchema.optional(), + serviceUrl: z.string().url().optional(), +}) {} + +export class InstanceAiVerifySearchRequest extends Z.class({ + connection: instanceAiConnectionSchema.optional(), +}) {} + +export type InstanceAiVerificationResponse = + | { + ok: true; + latencyMs?: number; + startupMs?: number; + resultCount?: number; + } + | { ok: false; failure: InstanceAiVerificationFailure }; + // --------------------------------------------------------------------------- // User preferences — per-user, self-service // --------------------------------------------------------------------------- diff --git a/packages/cli/src/modules/instance-ai/__tests__/instance-ai-settings.service.test.ts b/packages/cli/src/modules/instance-ai/__tests__/instance-ai-settings.service.test.ts index da071be18fe..64be087472d 100644 --- a/packages/cli/src/modules/instance-ai/__tests__/instance-ai-settings.service.test.ts +++ b/packages/cli/src/modules/instance-ai/__tests__/instance-ai-settings.service.test.ts @@ -46,7 +46,7 @@ describe('InstanceAiSettingsService', () => { sandboxProvider: 'n8n-sandbox', sandboxImage: '', sandboxTimeout: 60, - n8nSandboxServiceUrl: 'http://sandbox-api:8080', + n8nSandboxServiceUrl: '', n8nSandboxServiceApiKey: '', localGatewayDisabled: false, } as unknown as InstanceAiConfig, @@ -66,16 +66,35 @@ describe('InstanceAiSettingsService', () => { let service: InstanceAiSettingsService; let persistedSettingsValue: string | undefined; + const createService = () => + new InstanceAiSettingsService( + globalConfig as never, + dbLockService, + settingsRepository, + userRepository, + userService, + aiService, + credentialsService, + credentialsFinderService, + instanceCredentialBroker, + eventService, + ); beforeEach(() => { vi.resetAllMocks(); + vi.stubEnv('N8N_INSTANCE_AI_MODEL', ''); + vi.stubEnv('OPENAI_API_KEY', ''); + vi.stubEnv('ANTHROPIC_API_KEY', ''); persistedSettingsValue = undefined; logger.scoped.mockReturnValue(logger); Container.set(Logger, logger); Object.assign(globalConfig.instanceAi, { + model: 'openai/gpt-4', + modelApiKey: '', + modelUrl: '', sandboxEnabled: false, sandboxProvider: 'n8n-sandbox', - n8nSandboxServiceUrl: 'http://sandbox-api:8080', + n8nSandboxServiceUrl: '', n8nSandboxServiceApiKey: '', mcpServers: '', browserMcp: false, @@ -116,18 +135,7 @@ describe('InstanceAiSettingsService', () => { dbLockService.withLockContext.mockImplementation(async (_lockId, fn) => { return await fn(operationContext); }); - service = new InstanceAiSettingsService( - globalConfig as never, - dbLockService, - settingsRepository, - userRepository, - userService, - aiService, - credentialsService, - credentialsFinderService, - instanceCredentialBroker, - eventService, - ); + service = createService(); }); afterEach(() => { @@ -140,7 +148,7 @@ describe('InstanceAiSettingsService', () => { await expect( service.updateAdminSettings({ - sandboxEnabled: true, + sandboxImage: 'custom-image', }), ).rejects.toThrow(UnprocessableRequestError); }); @@ -150,25 +158,104 @@ describe('InstanceAiSettingsService', () => { await expect( service.updateAdminSettings({ - sandboxEnabled: true, + sandboxImage: 'custom-image', mcpServers: '[]', }), - ).rejects.toThrow(/sandboxEnabled.*mcpServers|mcpServers.*sandboxEnabled/); + ).rejects.toThrow(/sandboxImage.*mcpServers|mcpServers.*sandboxImage/); }); it('should reject environment-managed fields on self-hosted', async () => { aiService.isProxyEnabled.mockReturnValue(false); - await expect(service.updateAdminSettings({ sandboxEnabled: true })).rejects.toThrow( + await expect(service.updateAdminSettings({ sandboxImage: 'custom-image' })).rejects.toThrow( UnprocessableRequestError, ); await expect(service.updateAdminSettings({ mcpServers: '[]' })).rejects.toThrow( UnprocessableRequestError, ); + await service.updateAdminSettings({ + n8nSandboxServiceUrl: 'http://sandbox-api:8080', + }); + await expect(service.updateAdminSettings({ sandboxEnabled: true })).resolves.toBeDefined(); + }); + + it('rejects model connection fields managed by the environment', async () => { + globalConfig.instanceAi.modelApiKey = 'environment-key'; + + await expect( + service.updateAdminSettings({ + modelConnection: { type: 'openAiApi', data: { apiKey: 'database-key' } }, + modelName: 'gpt-5', + }), + ).rejects.toThrow('Cannot update environment-managed fields: modelConnection'); + await expect(service.updateAdminSettings({ modelName: 'gpt-5' })).resolves.toBeDefined(); + + vi.stubEnv('N8N_INSTANCE_AI_MODEL', 'openai/gpt-5'); + await expect(service.updateAdminSettings({ modelName: 'gpt-4' })).rejects.toThrow( + 'Cannot update environment-managed fields: modelName', + ); + }); + + it('rejects sandbox connection fields managed by the environment', async () => { + Object.assign(globalConfig.instanceAi, { + sandboxProvider: 'daytona', + daytonaApiKey: 'environment-key', + }); + service = createService(); + + await expect( + service.updateAdminSettings({ + sandboxConnection: { + type: 'daytonaApi', + data: { apiUrl: 'https://daytona.example.com', apiKey: 'database-key' }, + }, + }), + ).rejects.toThrow('Cannot update environment-managed fields: sandboxConnection'); + await expect(service.updateAdminSettings({ sandboxEnabled: true })).resolves.toBeDefined(); + }); + + it('rejects search decisions managed by the environment', async () => { + globalConfig.instanceAi.braveSearchApiKey = 'environment-key'; + + await expect(service.updateAdminSettings({ searchDisabled: true })).rejects.toThrow( + 'Cannot update environment-managed fields: searchDisabled', + ); + await expect( + service.updateAdminSettings({ + searchConnection: { type: 'braveSearchApi', data: { apiKey: 'database-key' } }, + }), + ).rejects.toThrow('Cannot update environment-managed fields: searchConnection'); + }); + + it('persists an explicit decision to disable web search and clears its assignment', async () => { + aiService.isProxyEnabled.mockReturnValue(false); + + const result = await service.updateAdminSettings({ searchDisabled: true }); + + expect(result.searchDisabled).toBe(true); + expect(instanceCredentialBroker.clearForUse).toHaveBeenCalledWith( + INSTANCE_AI_SEARCH_CREDENTIAL_POLICY, + operationContext, + ); + expect(persistedSettingsValue).toContain('"searchDisabled":true'); + }); + + it('rejects disabling web search while configuring a search connection', async () => { + aiService.isProxyEnabled.mockReturnValue(false); + + await expect( + service.updateAdminSettings({ + searchDisabled: true, + searchConnection: { type: 'braveSearchApi', data: { apiKey: 'key' } }, + }), + ).rejects.toThrow('Cannot disable web search while configuring a search connection'); }); it('should store service credential selections as broker assignments', async () => { aiService.isProxyEnabled.mockReturnValue(false); + await service.updateAdminSettings({ + n8nSandboxServiceUrl: 'http://sandbox-api:8080', + }); await service.updateAdminSettings({ daytonaCredentialId: 'daytona-cred', @@ -213,6 +300,9 @@ describe('InstanceAiSettingsService', () => { it('should reject an n8n sandbox credential whose header name is not x-api-key', async () => { aiService.isProxyEnabled.mockReturnValue(false); + await service.updateAdminSettings({ + n8nSandboxServiceUrl: 'http://sandbox-api:8080', + }); instanceCredentialBroker.resolveForUse.mockResolvedValue({ id: 'sandbox-cred', name: 'Sandbox header', @@ -227,6 +317,9 @@ describe('InstanceAiSettingsService', () => { it('should accept an n8n sandbox credential with the x-api-key header', async () => { aiService.isProxyEnabled.mockReturnValue(false); + await service.updateAdminSettings({ + n8nSandboxServiceUrl: 'http://sandbox-api:8080', + }); settingsRepository.upsert.mockResolvedValue(undefined as never); instanceCredentialBroker.resolveForUse.mockResolvedValue({ id: 'sandbox-cred', @@ -544,13 +637,17 @@ describe('InstanceAiSettingsService', () => { expect.objectContaining({ id: 'instance-ai:sandbox:n8n' }), operationContext, ); - expect(result.sandboxProvider).toBe('daytona'); + expect(result).toMatchObject({ sandboxEnabled: true, sandboxProvider: 'daytona' }); + expect(persistedSettingsValue).toContain('"sandboxEnabled":true'); }); it('should restore the environment sandbox provider when the connection is cleared', async () => { credentialsService.createInstanceCredential.mockResolvedValue({ id: 'daytona-cred', } as never); + await service.updateAdminSettings({ + n8nSandboxServiceUrl: 'http://sandbox-api:8080', + }); await service.updateAdminSettings( { @@ -732,6 +829,7 @@ describe('InstanceAiSettingsService', () => { it('should validate sandbox settings before running connection hooks', async () => { globalConfig.instanceAi.sandboxEnabled = true; globalConfig.instanceAi.n8nSandboxServiceUrl = ''; + service = createService(); await expect( service.updateAdminSettings( @@ -815,6 +913,8 @@ describe('InstanceAiSettingsService', () => { it('should clear the inactive Daytona slot when selecting n8n Sandbox behind the proxy', async () => { aiService.isProxyEnabled.mockReturnValue(true); + globalConfig.instanceAi.n8nSandboxServiceUrl = 'http://sandbox-api:8080'; + service = createService(); await expect( service.updateAdminSettings({ @@ -840,6 +940,8 @@ describe('InstanceAiSettingsService', () => { it('should accept n8n Sandbox connections on proxy deployments', async () => { aiService.isProxyEnabled.mockReturnValue(true); + globalConfig.instanceAi.n8nSandboxServiceUrl = 'http://sandbox-api:8080'; + service = createService(); credentialsService.createInstanceCredential.mockResolvedValue({ id: 'sandbox-cred', } as never); @@ -902,7 +1004,10 @@ describe('InstanceAiSettingsService', () => { }); await expect( - service.updateAdminSettings({ daytonaCredentialId: 'daytona-cred' }), + service.updateAdminSettings({ + daytonaCredentialId: 'daytona-cred', + sandboxProvider: 'daytona', + }), ).rejects.toThrow(/apiKey/); }); @@ -923,6 +1028,7 @@ describe('InstanceAiSettingsService', () => { it('should reject n8n sandbox selection without a service URL', async () => { globalConfig.instanceAi.sandboxEnabled = true; globalConfig.instanceAi.n8nSandboxServiceUrl = ''; + service = createService(); await expect( service.updateAdminSettings({ @@ -1298,6 +1404,26 @@ describe('InstanceAiSettingsService', () => { ); }); + it('uses the environment model connection without resolving stored credentials', async () => { + Object.assign(globalConfig.instanceAi, { + model: 'openai/gpt-5', + modelApiKey: 'environment-key', + }); + instanceCredentialBroker.resolveForUse.mockResolvedValue({ + id: 'stored-model', + name: 'Stored model', + type: 'openAiApi', + data: { apiKey: 'database-key' }, + }); + + await expect(service.resolveModelConfig(mock())).resolves.toEqual({ + id: 'openai/gpt-5', + url: '', + apiKey: 'environment-key', + }); + expect(instanceCredentialBroker.resolveForUse).not.toHaveBeenCalled(); + }); + it('reads the admin model name and same-id credential update in one locked snapshot', async () => { instanceCredentialBroker.resolveForUse.mockResolvedValue({ id: 'credential-a', @@ -1552,7 +1678,7 @@ describe('InstanceAiSettingsService', () => { }); describe('search credential', () => { - it('uses the resolved credential data for the search config', async () => { + it('uses environment search settings without resolving the stored credential', async () => { globalConfig.instanceAi.braveSearchApiKey = 'env-key'; globalConfig.instanceAi.searxngUrl = 'https://search.example.com'; instanceCredentialBroker.resolveForUse.mockResolvedValue({ @@ -1563,12 +1689,14 @@ describe('InstanceAiSettingsService', () => { }); await expect(service.resolveSearchConfig()).resolves.toEqual({ - braveApiKey: 'credential-key', + braveApiKey: 'env-key', + searxngUrl: 'https://search.example.com', }); + expect(instanceCredentialBroker.resolveForUse).not.toHaveBeenCalled(); expect(logger.warn).not.toHaveBeenCalled(); }); - it('falls back to environment config when the selected credential cannot be resolved', async () => { + it('does not resolve an unavailable stored credential when search is environment-managed', async () => { globalConfig.instanceAi.braveSearchApiKey = 'env-key'; globalConfig.instanceAi.searxngUrl = 'https://search.example.com'; instanceCredentialBroker.resolveForUse.mockRejectedValue(new Error('not found')); @@ -1577,13 +1705,11 @@ describe('InstanceAiSettingsService', () => { braveApiKey: 'env-key', searxngUrl: 'https://search.example.com', }); - expect(logger.warn).toHaveBeenCalledWith( - 'Could not resolve the configured search credential; using environment fallback', - { credentialUseId: 'instance-ai:search', error: 'not found' }, - ); + expect(instanceCredentialBroker.resolveForUse).not.toHaveBeenCalled(); + expect(logger.warn).not.toHaveBeenCalled(); }); - it('falls back to environment config when resolved credential data is incomplete', async () => { + it('does not inspect incomplete stored credential data when search is environment-managed', async () => { globalConfig.instanceAi.braveSearchApiKey = 'env-key'; globalConfig.instanceAi.searxngUrl = 'https://search.example.com'; instanceCredentialBroker.resolveForUse.mockResolvedValue({ @@ -1597,13 +1723,8 @@ describe('InstanceAiSettingsService', () => { braveApiKey: 'env-key', searxngUrl: 'https://search.example.com', }); - expect(logger.warn).toHaveBeenCalledWith( - 'Could not resolve the configured search credential; using environment fallback', - { - credentialUseId: 'instance-ai:search', - error: 'Credential data is incomplete', - }, - ); + expect(instanceCredentialBroker.resolveForUse).not.toHaveBeenCalled(); + expect(logger.warn).not.toHaveBeenCalled(); }); }); @@ -1673,7 +1794,8 @@ describe('InstanceAiSettingsService', () => { }); describe('n8n sandbox credential', () => { - it('uses the resolved api key instead of the environment api key', async () => { + it('uses the resolved api key for an admin-configured service URL', async () => { + globalConfig.instanceAi.n8nSandboxServiceUrl = 'http://sandbox-api:8080'; globalConfig.instanceAi.n8nSandboxServiceApiKey = 'env-key'; instanceCredentialBroker.resolveForUse.mockResolvedValue({ id: 'sandbox-credential', @@ -1688,8 +1810,29 @@ describe('InstanceAiSettingsService', () => { }); }); + it('uses environment settings without resolving the stored credential', async () => { + Object.assign(globalConfig.instanceAi, { + n8nSandboxServiceUrl: 'http://sandbox-api:8080', + n8nSandboxServiceApiKey: 'environment-key', + }); + service = createService(); + instanceCredentialBroker.resolveForUse.mockResolvedValue({ + id: 'sandbox-credential', + name: 'Sandbox', + type: 'httpHeaderAuth', + data: { name: 'X-Api-Key', value: 'credential-key' }, + }); + + await expect(service.resolveN8nSandboxConfig()).resolves.toEqual({ + serviceUrl: 'http://sandbox-api:8080', + apiKey: 'environment-key', + }); + expect(instanceCredentialBroker.resolveForUse).not.toHaveBeenCalled(); + }); + it('uses the resolved api key when the assistant proxy is enabled', async () => { aiService.isProxyEnabled.mockReturnValue(true); + globalConfig.instanceAi.n8nSandboxServiceUrl = 'http://sandbox-api:8080'; globalConfig.instanceAi.n8nSandboxServiceApiKey = 'env-key'; instanceCredentialBroker.resolveForUse.mockResolvedValue({ id: 'sandbox-credential', @@ -1705,6 +1848,7 @@ describe('InstanceAiSettingsService', () => { }); it('falls back to environment config when the credential header is not x-api-key', async () => { + globalConfig.instanceAi.n8nSandboxServiceUrl = 'http://sandbox-api:8080'; globalConfig.instanceAi.n8nSandboxServiceApiKey = 'env-key'; instanceCredentialBroker.resolveForUse.mockResolvedValue({ id: 'sandbox-credential', @@ -1727,6 +1871,7 @@ describe('InstanceAiSettingsService', () => { }); it('falls back to environment config when the api key is missing', async () => { + globalConfig.instanceAi.n8nSandboxServiceUrl = 'http://sandbox-api:8080'; globalConfig.instanceAi.n8nSandboxServiceApiKey = 'env-key'; instanceCredentialBroker.resolveForUse.mockResolvedValue({ id: 'sandbox-credential', @@ -1757,6 +1902,7 @@ describe('InstanceAiSettingsService', () => { modelUrl: '', n8nSandboxServiceUrl: '', }); + service = createService(); }); it('reports the model as env-configured when a custom or provider key is set', async () => { @@ -1780,6 +1926,7 @@ describe('InstanceAiSettingsService', () => { it('reports environment configuration for the selected sandbox provider', async () => { globalConfig.instanceAi.sandboxProvider = 'daytona'; globalConfig.instanceAi.daytonaApiKey = 'dtn-key'; + service = createService(); expect((await service.getAdminSettings()).sandboxEnvConfigured).toBe(true); globalConfig.instanceAi.daytonaApiKey = ''; @@ -1787,9 +1934,11 @@ describe('InstanceAiSettingsService', () => { globalConfig.instanceAi.sandboxProvider = 'n8n-sandbox'; globalConfig.instanceAi.n8nSandboxServiceUrl = 'http://sandbox-api:8080'; + service = createService(); expect((await service.getAdminSettings()).sandboxEnvConfigured).toBe(true); globalConfig.instanceAi.n8nSandboxServiceUrl = ''; + service = createService(); expect((await service.getAdminSettings()).sandboxEnvConfigured).toBe(false); }); @@ -1803,6 +1952,237 @@ describe('InstanceAiSettingsService', () => { globalConfig.instanceAi.searxngUrl = 'http://searxng:8080'; expect((await service.getAdminSettings()).searchEnvConfigured).toBe(true); }); + + it('only exposes env-management booleans for server-managed values', async () => { + globalConfig.instanceAi.modelApiKey = 'model-secret'; + globalConfig.instanceAi.braveSearchApiKey = 'search-secret'; + + const settings = await service.getAdminSettings(); + const serialized = JSON.stringify(settings); + + expect(settings.envManaged.model.apiKey).toBe(true); + expect(settings.envManaged.search.apiKey).toBe(true); + expect(serialized).not.toContain('model-secret'); + expect(serialized).not.toContain('search-secret'); + }); + }); + + describe('verification connection resolution', () => { + beforeEach(() => { + credentialsService.unredact.mockImplementation((_data, currentData) => currentData); + instanceCredentialBroker.resolveForUse.mockImplementation(async (policy) => { + if (policy.id === INSTANCE_AI_MODEL_CREDENTIAL_POLICY.id) { + return { + id: 'model-credential', + name: 'Model', + type: 'openAiApi', + data: { apiKey: 'saved-model-key' }, + } as never; + } + if (policy.id === INSTANCE_AI_N8N_SANDBOX_CREDENTIAL_POLICY.id) { + return { + id: 'sandbox-credential', + name: 'Sandbox', + type: 'httpHeaderAuth', + data: { name: 'x-api-key', value: 'saved-sandbox-key' }, + } as never; + } + return { + id: 'search-credential', + name: 'Search', + type: 'braveSearchApi', + data: { apiKey: 'saved-search-key' }, + } as never; + }); + }); + + it('restores redacted model, sandbox, and search fields on the server', async () => { + await expect( + service.resolveModelConnectionForVerification({ + type: 'openAiApi', + data: { apiKey: '__redacted__' }, + }), + ).resolves.toEqual({ type: 'openAiApi', data: { apiKey: 'saved-model-key' } }); + await expect( + service.resolveSandboxConnectionForVerification({ + type: 'httpHeaderAuth', + data: { name: 'x-api-key', value: '__redacted__' }, + }), + ).resolves.toEqual({ + type: 'httpHeaderAuth', + data: { name: 'x-api-key', value: 'saved-sandbox-key' }, + }); + await expect( + service.resolveSearchConnectionForVerification({ + type: 'braveSearchApi', + data: { apiKey: '__redacted__' }, + }), + ).resolves.toEqual({ type: 'braveSearchApi', data: { apiKey: 'saved-search-key' } }); + }); + + it('rejects a missing prepared connection', async () => { + await expect( + service.resolveModelConnectionForVerification(undefined as never), + ).rejects.toThrow('Prepared provider connection is missing'); + }); + }); + + describe('model verification config', () => { + it.each([ + ['openai/original', 'replacement', 'openai/replacement'], + ['original', 'replacement', 'custom/replacement'], + [ + { id: 'anthropic/original', url: '', apiKey: 'key' }, + 'replacement', + { id: 'anthropic/replacement', url: '', apiKey: 'key' }, + ], + [ + { id: 'original', url: '', apiKey: 'key' }, + 'replacement', + { id: 'custom/replacement', url: '', apiKey: 'key' }, + ], + ] as const)( + 'replaces the selected model while keeping its provider', + async (config, model, expected) => { + vi.spyOn(service, 'resolveModelConfig').mockResolvedValue(config as never); + + await expect( + service.resolveModelConfigForVerification(mock(), model), + ).resolves.toEqual(expected); + }, + ); + + it('keeps the resolved config when no model is selected or the config is opaque', async () => { + const opaqueConfig = { provider: 'custom' }; + const resolveModelConfig = vi + .spyOn(service, 'resolveModelConfig') + .mockResolvedValueOnce('openai/original') + .mockResolvedValueOnce(opaqueConfig as never); + + await expect(service.resolveModelConfigForVerification(mock())).resolves.toBe( + 'openai/original', + ); + await expect( + service.resolveModelConfigForVerification(mock(), 'replacement'), + ).resolves.toBe(opaqueConfig); + expect(resolveModelConfig).toHaveBeenCalledTimes(2); + }); + + it('builds and validates a model config from a draft connection', () => { + expect( + service.buildModelConfigForConnection( + { type: 'openAiApi', data: { apiKey: 'key' } }, + 'gpt-5.4', + ), + ).toEqual({ id: 'openai/gpt-5.4', url: '', apiKey: 'key' }); + + expect(() => + service.buildModelConfigForConnection( + { type: 'braveSearchApi', data: { apiKey: 'key' } }, + 'gpt-5.4', + ), + ).toThrow('is not supported for the model'); + expect(() => + service.buildModelConfigForConnection({ type: 'openAiApi', data: {} }, 'gpt-5.4'), + ).toThrow('The field "apiKey" or "url" is required'); + }); + + it('builds model configs from environment URL and API key combinations', async () => { + aiService.isProxyEnabled.mockReturnValue(false); + Object.assign(globalConfig.instanceAi, { + model: 'openai/gpt-4', + modelUrl: 'https://model.example.com/v1', + modelApiKey: 'model-key', + }); + + await expect(service.resolveModelConfig(mock())).resolves.toEqual({ + id: 'openai/gpt-4', + url: 'https://model.example.com/v1', + apiKey: 'model-key', + }); + + globalConfig.instanceAi.modelUrl = ''; + await expect(service.resolveModelConfig(mock())).resolves.toEqual({ + id: 'openai/gpt-4', + url: '', + apiKey: 'model-key', + }); + + globalConfig.instanceAi.modelApiKey = ''; + await expect(service.resolveModelConfig(mock())).resolves.toBe('openai/gpt-4'); + }); + }); + + describe('isSetupCompleted', () => { + it('is complete on managed deployments', async () => { + globalConfig.deployment.type = 'cloud'; + await expect(service.isSetupCompleted()).resolves.toBe(true); + + globalConfig.deployment.type = 'default'; + aiService.isProxyEnabled.mockReturnValue(true); + await expect(service.isSetupCompleted()).resolves.toBe(true); + expect(instanceCredentialBroker.getAssignedCredentialId).not.toHaveBeenCalled(); + }); + + it('accepts a setup fully configured through environment variables', async () => { + aiService.isProxyEnabled.mockReturnValue(false); + Object.assign(globalConfig.instanceAi, { + modelApiKey: 'model-key', + sandboxEnabled: true, + sandboxProvider: 'daytona', + daytonaApiKey: 'sandbox-key', + braveSearchApiKey: 'search-key', + }); + service = createService(); + + await expect(service.isSetupCompleted()).resolves.toBe(true); + }); + + it('accepts Daytona and search credential assignments', async () => { + aiService.isProxyEnabled.mockReturnValue(false); + persistedSettingsValue = JSON.stringify({ + modelName: 'gpt-5.4', + sandboxEnabled: true, + sandboxProvider: 'daytona', + }); + instanceCredentialBroker.getAssignedCredentialId.mockImplementation(async (policy) => { + if (policy.id === INSTANCE_AI_MODEL_CREDENTIAL_POLICY.id) return 'model-credential'; + if (policy.id === INSTANCE_AI_DAYTONA_CREDENTIAL_POLICY.id) return 'sandbox-credential'; + if (policy.id === INSTANCE_AI_SEARCH_CREDENTIAL_POLICY.id) return 'search-credential'; + return null; + }); + await service.reloadFromDb(); + + await expect(service.isSetupCompleted()).resolves.toBe(true); + }); + + it('requires an explicit web-search decision after model and sandbox are configured', async () => { + aiService.isProxyEnabled.mockReturnValue(false); + persistedSettingsValue = JSON.stringify({ + modelName: 'gpt-5.4', + sandboxEnabled: true, + sandboxProvider: 'n8n-sandbox', + }); + instanceCredentialBroker.getAssignedCredentialId.mockImplementation(async (policy) => { + if (policy.id === INSTANCE_AI_MODEL_CREDENTIAL_POLICY.id) return 'model-credential'; + if (policy.id === INSTANCE_AI_N8N_SANDBOX_CREDENTIAL_POLICY.id) { + return 'sandbox-credential'; + } + return null; + }); + + await expect(service.isSetupCompleted()).resolves.toBe(false); + + persistedSettingsValue = JSON.stringify({ + modelName: 'gpt-5.4', + sandboxEnabled: true, + sandboxProvider: 'n8n-sandbox', + searchDisabled: true, + }); + await service.reloadFromDb(); + + await expect(service.isSetupCompleted()).resolves.toBe(true); + }); }); describe('service credential assignments', () => { @@ -1823,6 +2203,25 @@ describe('InstanceAiSettingsService', () => { }); }); + it('hides stored assignments when environment connections are active', async () => { + Object.assign(globalConfig.instanceAi, { + modelApiKey: 'model-environment-key', + n8nSandboxServiceUrl: 'http://sandbox-api:8080', + braveSearchApiKey: 'search-environment-key', + }); + service = createService(); + instanceCredentialBroker.getAssignedCredentialId.mockImplementation(async (credentialUse) => { + return `${credentialUse.id}-credential`; + }); + + await expect(service.getAdminSettings()).resolves.toMatchObject({ + modelCredentialId: null, + daytonaCredentialId: null, + n8nSandboxCredentialId: null, + searchCredentialId: null, + }); + }); + it('exposes n8n Sandbox assignments when the assistant proxy is enabled', async () => { aiService.isProxyEnabled.mockReturnValue(true); instanceCredentialBroker.getAssignedCredentialId.mockResolvedValue('sandbox-cred'); diff --git a/packages/cli/src/modules/instance-ai/__tests__/instance-ai-verification.service.test.ts b/packages/cli/src/modules/instance-ai/__tests__/instance-ai-verification.service.test.ts new file mode 100644 index 00000000000..67dcd9cd9de --- /dev/null +++ b/packages/cli/src/modules/instance-ai/__tests__/instance-ai-verification.service.test.ts @@ -0,0 +1,391 @@ +import type { InstanceAiVerificationFailure } from '@n8n/api-types'; +import type { Logger } from '@n8n/backend-common'; +import type { OutboundHttp } from '@n8n/backend-network'; +import type { GlobalConfig, InstanceAiConfig } from '@n8n/config'; +import type { User } from '@n8n/db'; +import { mock } from 'vitest-mock-extended'; + +const raceWithAbortMock = vi.hoisted(() => vi.fn()); + +vi.mock('@n8n/agents', () => ({ createModel: vi.fn(), raceWithAbort: raceWithAbortMock })); +vi.mock('ai', () => ({ generateText: vi.fn() })); +vi.mock('@n8n/instance-ai', () => ({ createSandbox: vi.fn(), createWorkspace: vi.fn() })); +vi.mock('@n8n/ai-utilities', () => ({ braveSearch: vi.fn(), searxngSearch: vi.fn() })); +vi.mock('@/utils/ai-proxy-fetch', () => ({ createAiProxyFetch: vi.fn(() => vi.fn()) })); + +import { braveSearch, searxngSearch } from '@n8n/ai-utilities'; +import { createModel } from '@n8n/agents'; +import { createSandbox, createWorkspace } from '@n8n/instance-ai'; +import { generateText } from 'ai'; + +import type { InstanceAiModelService } from '../instance-ai-model.service'; +import type { InstanceAiSettingsService } from '../instance-ai-settings.service'; +import { InstanceAiVerificationService } from '../instance-ai-verification.service'; + +describe('InstanceAiVerificationService', () => { + const globalConfig = mock({ + instanceAi: { + sandboxProvider: 'n8n-sandbox', + sandboxImage: 'sandbox-image', + sandboxTimeout: 60, + n8nSandboxServiceUrl: 'https://env.sandbox', + n8nSandboxServiceApiKey: 'env-sandbox-key', + daytonaApiUrl: 'https://env.daytona', + daytonaApiKey: 'env-daytona-key', + } as unknown as InstanceAiConfig, + }); + const logger = mock(); + const settingsService = mock(); + const modelService = mock(); + const outboundHttp = mock(); + const user = mock(); + const createModelMock = vi.mocked(createModel); + const generateTextMock = vi.mocked(generateText); + const createSandboxMock = vi.mocked(createSandbox); + const createWorkspaceMock = vi.mocked(createWorkspace); + const braveSearchMock = vi.mocked(braveSearch); + const searxngSearchMock = vi.mocked(searxngSearch); + + let service: InstanceAiVerificationService; + + beforeEach(() => { + vi.resetAllMocks(); + raceWithAbortMock.mockImplementation( + async (work: Promise | (() => Promise)) => + await (typeof work === 'function' ? work() : work), + ); + Object.assign(globalConfig.instanceAi, { + sandboxProvider: 'n8n-sandbox', + sandboxImage: 'sandbox-image', + sandboxTimeout: 60, + n8nSandboxServiceUrl: 'https://env.sandbox', + n8nSandboxServiceApiKey: 'env-sandbox-key', + daytonaApiUrl: 'https://env.daytona', + daytonaApiKey: 'env-daytona-key', + }); + modelService.resolveAgentModelConfig.mockResolvedValue('openai/saved-model'); + createModelMock.mockReturnValue({} as never); + generateTextMock.mockResolvedValue({} as never); + service = new InstanceAiVerificationService( + logger, + globalConfig, + settingsService, + modelService, + outboundHttp, + ); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('verifyModel', () => { + it('verifies a draft connection with restored credential data', async () => { + const connection = { type: 'openAiApi', data: { apiKey: '__redacted__' } }; + const restored = { type: 'openAiApi', data: { apiKey: 'saved-key' } }; + const modelConfig = { id: 'openai/gpt-5.4', url: '', apiKey: 'saved-key' } as const; + settingsService.resolveModelConnectionForVerification.mockResolvedValue(restored); + settingsService.buildModelConfigForConnection.mockReturnValue(modelConfig); + + await expect( + service.verifyModel(user, { connection, modelName: 'gpt-5.4' }), + ).resolves.toMatchObject({ ok: true, latencyMs: expect.any(Number) }); + + expect(settingsService.resolveModelConnectionForVerification).toHaveBeenCalledWith( + connection, + ); + expect(settingsService.buildModelConfigForConnection).toHaveBeenCalledWith( + restored, + 'gpt-5.4', + ); + expect(createModelMock).toHaveBeenCalledWith(modelConfig, expect.any(Function)); + expect(generateTextMock).toHaveBeenCalledWith( + expect.objectContaining({ prompt: 'Reply with OK.', maxOutputTokens: 8 }), + ); + }); + + it('uses the saved model configuration when no draft connection is provided', async () => { + settingsService.resolveModelConfigForVerification.mockResolvedValue( + 'anthropic/claude-opus-4-6', + ); + + await expect( + service.verifyModel(user, { modelName: 'claude-opus-4-6' }), + ).resolves.toMatchObject({ + ok: true, + }); + + expect(settingsService.resolveModelConfigForVerification).toHaveBeenCalledWith( + user, + 'claude-opus-4-6', + ); + expect(modelService.resolveAgentModelConfig).not.toHaveBeenCalled(); + }); + + it('uses the active agent model when no model override is provided', async () => { + await expect(service.verifyModel(user, {})).resolves.toMatchObject({ ok: true }); + + expect(modelService.resolveAgentModelConfig).toHaveBeenCalledWith(user); + expect(createModelMock).toHaveBeenCalledWith('openai/saved-model', expect.any(Function)); + }); + + it.each<{ error: unknown; failure: InstanceAiVerificationFailure }>([ + { error: { status: 401 }, failure: 'unauthorized' }, + { error: { statusCode: '403', message: 'Access forbidden' }, failure: 'forbidden' }, + { + error: Object.assign(new Error('Quota limit reached'), { code: 403 }), + failure: 'quota_exceeded', + }, + { error: { status: 429 }, failure: 'rate_limited' }, + { error: Object.assign(new Error('aborted'), { name: 'AbortError' }), failure: 'timeout' }, + { + error: Object.assign(new Error('timed out'), { name: 'TimeoutError' }), + failure: 'timeout', + }, + { error: new Error('Provider returned 401'), failure: 'unauthorized' }, + { error: new Error('Provider returned 403: limit exceeded'), failure: 'quota_exceeded' }, + { error: new Error('Provider returned 403'), failure: 'forbidden' }, + { error: new Error('Provider returned 429'), failure: 'rate_limited' }, + { error: new Error('Request timeout'), failure: 'timeout' }, + { error: new Error('fetch failed: ECONNREFUSED'), failure: 'unreachable' }, + { error: new Error('Invalid JSON response'), failure: 'invalid_response' }, + { error: new Error('Unexpected provider failure'), failure: 'provider_error' }, + ])('classifies model verification failures as $failure', async ({ error, failure }) => { + generateTextMock.mockRejectedValueOnce(error); + + await expect(service.verifyModel(user, {})).resolves.toEqual({ ok: false, failure }); + expect(logger.warn).toHaveBeenCalledWith( + 'Instance AI model verification failed', + expect.objectContaining({ error: expect.any(String), failure }), + ); + }); + }); + + describe('verifySandbox', () => { + it('verifies an n8n Sandbox draft and destroys the workspace', async () => { + const timeoutSpy = vi.spyOn(AbortSignal, 'timeout'); + const connection = { type: 'httpHeaderAuth', data: { value: '__redacted__' } }; + settingsService.resolveSandboxConnectionForVerification.mockResolvedValue({ + type: 'httpHeaderAuth', + data: { value: 'saved-key' }, + }); + const workspace = { + init: vi.fn().mockResolvedValue(undefined), + destroy: vi.fn().mockResolvedValue(undefined), + sandbox: { executeCommand: vi.fn().mockResolvedValue({ exitCode: 0 }) }, + }; + createSandboxMock.mockResolvedValue({} as never); + createWorkspaceMock.mockReturnValue(workspace as never); + + await expect( + service.verifySandbox(user, { + provider: 'n8n-sandbox', + connection, + serviceUrl: 'https://draft.sandbox', + }), + ).resolves.toMatchObject({ ok: true, startupMs: expect.any(Number) }); + + expect(settingsService.resolveSandboxConnectionForVerification).toHaveBeenCalledWith( + connection, + ); + expect(createSandboxMock).toHaveBeenCalledWith({ + enabled: true, + provider: 'n8n-sandbox', + serviceUrl: 'https://draft.sandbox', + apiKey: 'saved-key', + timeout: 60, + }); + expect(timeoutSpy).toHaveBeenCalledWith(60); + expect(workspace.sandbox.executeCommand).toHaveBeenCalledWith('printf', ['ok'], { + abortSignal: expect.any(AbortSignal), + }); + expect(workspace.destroy).toHaveBeenCalled(); + }); + + it('returns a timeout and starts cleanup when sandbox verification exceeds the deadline', async () => { + const timeoutError = Object.assign(new Error('Sandbox verification timed out'), { + name: 'TimeoutError', + }); + let raceCount = 0; + raceWithAbortMock.mockImplementation( + async (work: Promise | (() => Promise)) => { + raceCount++; + if (raceCount === 3) throw timeoutError; + return await (typeof work === 'function' ? work() : work); + }, + ); + const workspace = { + init: vi.fn(), + destroy: vi.fn().mockResolvedValue(undefined), + sandbox: { executeCommand: vi.fn() }, + }; + createSandboxMock.mockResolvedValue({} as never); + createWorkspaceMock.mockReturnValue(workspace as never); + + await expect(service.verifySandbox(user, {})).resolves.toEqual({ + ok: false, + failure: 'timeout', + }); + + expect(workspace.destroy).toHaveBeenCalledOnce(); + expect(workspace.init).toHaveBeenCalledOnce(); + expect(workspace.sandbox.executeCommand).not.toHaveBeenCalled(); + }); + + it('verifies saved Daytona settings', async () => { + settingsService.resolveDaytonaConfig.mockResolvedValue({ + apiUrl: 'https://saved.daytona', + apiKey: 'saved-daytona-key', + }); + const workspace = { + init: vi.fn().mockResolvedValue(undefined), + destroy: vi.fn().mockRejectedValue(new Error('cleanup failed')), + sandbox: { executeCommand: vi.fn().mockResolvedValue({ exitCode: 0 }) }, + }; + createSandboxMock.mockResolvedValue({} as never); + createWorkspaceMock.mockReturnValue(workspace as never); + + await expect(service.verifySandbox(user, { provider: 'daytona' })).resolves.toMatchObject({ + ok: true, + }); + + expect(createSandboxMock).toHaveBeenCalledWith({ + enabled: true, + provider: 'daytona', + daytonaApiUrl: 'https://saved.daytona', + daytonaApiKey: 'saved-daytona-key', + image: 'sandbox-image', + timeout: 60, + ephemeral: true, + }); + expect(logger.warn).toHaveBeenCalledWith( + 'Instance AI sandbox verification cleanup failed', + expect.objectContaining({ error: expect.any(String), provider: 'daytona' }), + ); + }); + + it('uses saved n8n Sandbox settings when no draft is provided', async () => { + settingsService.resolveN8nSandboxConfig.mockResolvedValue({ + serviceUrl: 'https://saved.sandbox', + apiKey: 'saved-key', + }); + const workspace = { + init: vi.fn().mockResolvedValue(undefined), + destroy: vi.fn().mockResolvedValue(undefined), + sandbox: { executeCommand: vi.fn().mockResolvedValue({ exitCode: 0 }) }, + }; + createSandboxMock.mockResolvedValue({} as never); + createWorkspaceMock.mockReturnValue(workspace as never); + + await expect(service.verifySandbox(user, {})).resolves.toMatchObject({ ok: true }); + + expect(createSandboxMock).toHaveBeenCalledWith({ + enabled: true, + provider: 'n8n-sandbox', + serviceUrl: 'https://saved.sandbox', + apiKey: 'saved-key', + timeout: 60, + }); + }); + + it.each([ + ['missing sandbox', undefined, undefined], + ['missing workspace', {}, undefined], + ['failed command', {}, { exitCode: 1 }], + ] as const)('reports provider errors for a %s', async (_name, sandbox, commandResult) => { + createSandboxMock.mockResolvedValue(sandbox as never); + if (sandbox && commandResult === undefined) { + createWorkspaceMock.mockReturnValue(undefined as never); + } else if (sandbox) { + createWorkspaceMock.mockReturnValue({ + init: vi.fn().mockResolvedValue(undefined), + destroy: vi.fn().mockResolvedValue(undefined), + sandbox: { executeCommand: vi.fn().mockResolvedValue(commandResult) }, + } as never); + } + + await expect(service.verifySandbox(user, {})).resolves.toEqual({ + ok: false, + failure: 'provider_error', + }); + }); + + it('maps Daytona forbidden responses to quota errors', async () => { + createSandboxMock.mockRejectedValue({ status: 403, message: 'Forbidden' }); + + await expect(service.verifySandbox(user, { provider: 'daytona' })).resolves.toEqual({ + ok: false, + failure: 'quota_exceeded', + }); + expect(logger.warn).toHaveBeenCalledWith( + 'Instance AI sandbox verification failed', + expect.objectContaining({ + error: expect.any(String), + failure: 'quota_exceeded', + provider: 'daytona', + }), + ); + }); + }); + + describe('verifySearch', () => { + it('verifies a Brave draft with restored credential data', async () => { + const connection = { type: 'braveSearchApi', data: { apiKey: '__redacted__' } }; + settingsService.resolveSearchConnectionForVerification.mockResolvedValue({ + type: 'braveSearchApi', + data: { apiKey: ' saved-key ' }, + }); + braveSearchMock.mockResolvedValue({ results: [{}, {}] } as never); + + await expect(service.verifySearch({ connection })).resolves.toEqual({ + ok: true, + resultCount: 2, + }); + + expect(braveSearchMock).toHaveBeenCalledWith( + 'saved-key', + 'n8n workflow automation', + expect.objectContaining({ maxResults: 10 }), + ); + expect(searxngSearchMock).not.toHaveBeenCalled(); + }); + + it('verifies a saved SearXNG connection', async () => { + settingsService.resolveSearchConfig.mockResolvedValue({ + searxngUrl: 'https://saved.searxng', + }); + searxngSearchMock.mockResolvedValue({ results: [{}] } as never); + + await expect(service.verifySearch({})).resolves.toEqual({ ok: true, resultCount: 1 }); + + expect(searxngSearchMock).toHaveBeenCalledWith( + 'https://saved.searxng', + 'n8n workflow automation', + expect.objectContaining({ maxResults: 10 }), + ); + }); + + it('reports an unconfigured provider without exposing connection details', async () => { + settingsService.resolveSearchConfig.mockResolvedValue({}); + + await expect(service.verifySearch({})).resolves.toEqual({ + ok: false, + failure: 'provider_error', + }); + }); + + it('classifies search provider failures', async () => { + settingsService.resolveSearchConfig.mockResolvedValue({ braveApiKey: 'saved-key' }); + braveSearchMock.mockRejectedValue(new Error('network request failed')); + + await expect(service.verifySearch({})).resolves.toEqual({ + ok: false, + failure: 'unreachable', + }); + expect(logger.warn).toHaveBeenCalledWith( + 'Instance AI search verification failed', + expect.objectContaining({ error: expect.any(String), failure: 'unreachable' }), + ); + }); + }); +}); diff --git a/packages/cli/src/modules/instance-ai/instance-ai-settings.service.ts b/packages/cli/src/modules/instance-ai/instance-ai-settings.service.ts index 61b9aac85d0..cd8be17bea0 100644 --- a/packages/cli/src/modules/instance-ai/instance-ai-settings.service.ts +++ b/packages/cli/src/modules/instance-ai/instance-ai-settings.service.ts @@ -121,7 +121,7 @@ function validateModelCredential({ data, }: { type: string; - data: ICredentialDataDecryptedObject; + data: Record; }): void { const apiKey = data.apiKey; if (typeof apiKey === 'string' && apiKey.trim().length > 0) return; @@ -137,7 +137,7 @@ function validateModelCredential({ function modelCredentialHeaders( credentialType: string, - data: ICredentialDataDecryptedObject, + data: Record, ): Record | undefined { const headers: Record = {}; if (credentialType === 'openAiApi' && typeof data.organizationId === 'string') { @@ -250,6 +250,8 @@ interface PersistedAdminSettings { sandboxImage?: string; sandboxTimeout?: number; modelName?: string | null; + searchDisabled?: boolean; + n8nSandboxServiceUrl?: string | null; localGatewayDisabled?: boolean; browserUseEnabled?: boolean; } @@ -300,6 +302,13 @@ export class InstanceAiSettingsService { private permissions: InstanceAiPermissions = { ...DEFAULT_INSTANCE_AI_PERMISSIONS }; private adminModelName: string | null = null; + + private searchDisabled = false; + + private adminN8nSandboxServiceUrl: string | null = null; + + private readonly environmentN8nSandboxServiceUrl: string; + constructor( globalConfig: GlobalConfig, private readonly dbLockService: DbLockService, @@ -315,6 +324,7 @@ export class InstanceAiSettingsService { this.config = globalConfig.instanceAi; this.deploymentConfig = globalConfig.deployment; this.environmentSandboxProvider = normalizeSandboxProvider(this.config.sandboxProvider); + this.environmentN8nSandboxServiceUrl = this.config.n8nSandboxServiceUrl; this.config.sandboxProvider = this.environmentSandboxProvider; } @@ -407,29 +417,71 @@ export class InstanceAiSettingsService { const modelProviderApiKeyEnv = MODEL_PROVIDER_API_KEY_ENV.get(c.model.split('/', 1)[0] ?? ''); const isProxyEnabled = this.aiService.isProxyEnabled(); const isManaged = this.isCloud || isProxyEnabled; - const sandboxProvider = normalizeSandboxProvider(c.sandboxProvider); + const providerModelApiKeyConfigured = Boolean( + modelProviderApiKeyEnv && process.env[modelProviderApiKeyEnv]?.trim(), + ); + const modelConnectionEnvConfigured = Boolean( + c.modelApiKey.trim() || c.modelUrl.trim() || providerModelApiKeyConfigured, + ); + const sandboxEnvConfigured = this.hasEnvironmentSandboxConnection(); + const searchEnvConfigured = this.hasEnvironmentSearchConnection(); + const directEnvironmentConfig = !isManaged; + const sandboxProvider = normalizeSandboxProvider( + directEnvironmentConfig && sandboxEnvConfigured + ? this.environmentSandboxProvider + : c.sandboxProvider, + ); return { enabled: this.enabled, permissions: { ...this.permissions }, mcpAccessEnabled: this.mcpAccessEnabled, sandboxEnabled: c.sandboxEnabled, sandboxProvider, - daytonaCredentialId: isManaged ? null : credentialSelection.daytonaCredentialId, - n8nSandboxCredentialId: this.isCloud ? null : credentialSelection.n8nSandboxCredentialId, - searchCredentialId: isManaged ? null : credentialSelection.searchCredentialId, - modelCredentialId: isManaged ? null : credentialSelection.modelCredentialId, - modelName: isManaged ? null : credentialSelection.modelName, - modelEnvConfigured: Boolean( - c.modelApiKey.trim() || - c.modelUrl.trim() || - (modelProviderApiKeyEnv && process.env[modelProviderApiKeyEnv]?.trim()), - ), - // n8n-sandbox needs only the URL; the service accepts keyless clients. - sandboxEnvConfigured: - sandboxProvider === 'daytona' - ? isProxyEnabled || Boolean(c.daytonaApiKey.trim()) - : Boolean(c.n8nSandboxServiceUrl.trim()), - searchEnvConfigured: Boolean(c.braveSearchApiKey.trim() || c.searxngUrl.trim()), + daytonaCredentialId: + isManaged || (directEnvironmentConfig && sandboxEnvConfigured) + ? null + : credentialSelection.daytonaCredentialId, + n8nSandboxCredentialId: + this.isCloud || (directEnvironmentConfig && sandboxEnvConfigured) + ? null + : credentialSelection.n8nSandboxCredentialId, + searchCredentialId: + isManaged || (directEnvironmentConfig && searchEnvConfigured) + ? null + : credentialSelection.searchCredentialId, + modelCredentialId: + isManaged || (directEnvironmentConfig && modelConnectionEnvConfigured) + ? null + : credentialSelection.modelCredentialId, + modelName: isManaged || this.hasEnvironmentModelName() ? null : credentialSelection.modelName, + modelEnvConfigured: modelConnectionEnvConfigured, + sandboxEnvConfigured, + searchEnvConfigured, + searchDisabled: directEnvironmentConfig && searchEnvConfigured ? false : this.searchDisabled, + n8nSandboxServiceUrl: this.environmentN8nSandboxServiceUrl + ? null + : this.adminN8nSandboxServiceUrl, + envManaged: { + model: { + provider: modelConnectionEnvConfigured, + apiKey: Boolean(c.modelApiKey.trim() || providerModelApiKeyConfigured), + baseUrl: Boolean(c.modelUrl.trim()), + model: Boolean(process.env.N8N_INSTANCE_AI_MODEL?.trim()), + }, + sandbox: { + provider: Boolean(process.env.N8N_INSTANCE_AI_SANDBOX_PROVIDER?.trim()), + serviceUrl: Boolean(this.environmentN8nSandboxServiceUrl.trim()), + apiKey: + sandboxProvider === 'daytona' + ? Boolean(c.daytonaApiKey.trim()) + : Boolean(c.n8nSandboxServiceApiKey.trim()), + }, + search: { + provider: Boolean(c.braveSearchApiKey.trim() || c.searxngUrl.trim()), + apiKey: Boolean(c.braveSearchApiKey.trim()), + url: Boolean(c.searxngUrl.trim()), + }, + }, localGatewayDisabled: this.isLocalGatewayDisabled(), browserUseEnabled: this.isBrowserUseEnabled(), }; @@ -439,6 +491,7 @@ export class InstanceAiSettingsService { update: InstanceAiAdminSettingsUpdateRequest, user?: User, ): Promise { + this.rejectEnvironmentManagedFields(update); this.rejectManagedFields( update, InstanceAiSettingsService.MANAGED_ADMIN_FIELDS, @@ -450,14 +503,28 @@ export class InstanceAiSettingsService { InstanceAiSettingsService.INSTANCE_CREDENTIAL_FIELDS, this.deploymentLabel(), ); - this.rejectManagedFields(update, ['modelName', 'sandboxProvider'], this.deploymentLabel()); + this.rejectManagedFields( + update, + [ + 'modelName', + 'sandboxProvider', + 'sandboxEnabled', + 'n8nSandboxServiceUrl', + 'searchDisabled', + ], + this.deploymentLabel(), + ); } else if (this.aiService.isProxyEnabled()) { this.rejectManagedFields( update, ['modelCredentialId', 'searchCredentialId', 'modelConnection', 'searchConnection'], this.deploymentLabel(), ); - this.rejectManagedFields(update, ['modelName'], this.deploymentLabel()); + this.rejectManagedFields( + update, + ['modelName', 'sandboxEnabled', 'n8nSandboxServiceUrl', 'searchDisabled'], + this.deploymentLabel(), + ); if (update.daytonaCredentialId !== null) { this.rejectManagedFields(update, ['daytonaCredentialId'], this.deploymentLabel()); } @@ -479,6 +546,16 @@ export class InstanceAiSettingsService { let daytonaCredentialId = initialDaytonaCredentialId; let n8nSandboxCredentialId = initialN8nSandboxCredentialId; let searchCredentialId = initialSearchCredentialId; + if (settingsUpdate.searchDisabled === true) { + if (searchConnection) { + throw new UnprocessableRequestError( + 'Cannot disable web search while configuring a search connection', + ); + } + searchCredentialId = null; + } else if (searchConnection || typeof searchCredentialId === 'string') { + settingsUpdate.searchDisabled = false; + } this.rejectConnectionConflicts(update); if ( @@ -614,8 +691,14 @@ export class InstanceAiSettingsService { n8nSandboxCredentialId !== undefined) && nextDaytonaCredentialId === null && nextN8nCredentialId === null; + const assignsSandboxConnection = + (sandboxConnection !== undefined || + daytonaCredentialId !== undefined || + n8nSandboxCredentialId !== undefined) && + (nextDaytonaCredentialId !== null || nextN8nCredentialId !== null); + if (assignsSandboxConnection) settingsUpdate.sandboxEnabled = true; this.validateAdminSettingsUpdate( - update, + settingsUpdate, current, clearsSandboxConnection ? this.environmentSandboxProvider @@ -642,7 +725,7 @@ export class InstanceAiSettingsService { 'modelName must be set together with modelCredentialId', ); } - if (hasModelName && !hasCredential) { + if (hasModelName && !hasCredential && !this.hasEnvironmentModelConnection()) { throw new UnprocessableRequestError('modelName requires modelCredentialId'); } } @@ -987,6 +1070,46 @@ export class InstanceAiSettingsService { // ── Shared accessors ────────────────────────────────────────────────── + /** Restores redacted fields from the assigned credential without sending them to the client. */ + async resolveModelConnectionForVerification( + connection: InstanceAiConnectionUpdate, + ): Promise { + const prepared = await this.prepareConnection( + INSTANCE_AI_MODEL_CREDENTIAL_POLICY, + 'AI Assistant model', + connection, + ); + return this.connectionForVerification(prepared); + } + + async resolveSandboxConnectionForVerification( + connection: InstanceAiConnectionUpdate, + ): Promise { + const prepared = await this.prepareSandboxConnection(connection); + return this.connectionForVerification(prepared); + } + + async resolveSearchConnectionForVerification( + connection: InstanceAiConnectionUpdate, + ): Promise { + const prepared = await this.prepareConnection( + INSTANCE_AI_SEARCH_CREDENTIAL_POLICY, + 'AI Assistant web search', + connection, + ); + return this.connectionForVerification(prepared); + } + + private connectionForVerification( + prepared: PreparedConnection | undefined, + ): InstanceAiConnectionUpdate { + if (!prepared) throw new UnexpectedError('Prepared provider connection is missing'); + return { + type: prepared.credential.type, + data: prepared.credential.data, + }; + } + async listInstanceModelCredentials(): Promise { if (this.isCloud || this.aiService.isProxyEnabled()) return []; const instanceCredentials = await this.instanceCredentialBroker.listForUse( @@ -1024,6 +1147,13 @@ export class InstanceAiSettingsService { apiUrl: daytonaApiUrl || undefined, apiKey: daytonaApiKey || undefined, }; + if ( + this.isDirectSelfManaged() && + this.environmentSandboxProvider === 'daytona' && + this.hasEnvironmentSandboxConnection() + ) { + return envConfig; + } const resolved = await this.resolveServiceCredential( INSTANCE_AI_DAYTONA_CREDENTIAL_POLICY, 'Daytona sandbox', @@ -1049,6 +1179,13 @@ export class InstanceAiSettingsService { serviceUrl: n8nSandboxServiceUrl || undefined, apiKey: n8nSandboxServiceApiKey || undefined, }; + if ( + this.isDirectSelfManaged() && + this.environmentSandboxProvider === 'n8n-sandbox' && + this.hasEnvironmentSandboxConnection() + ) { + return envConfig; + } const resolved = await this.resolveServiceCredential( INSTANCE_AI_N8N_SANDBOX_CREDENTIAL_POLICY, 'n8n Sandbox', @@ -1083,6 +1220,7 @@ export class InstanceAiSettingsService { braveApiKey: braveSearchApiKey || undefined, searxngUrl: searxngUrl || undefined, }; + if (this.isDirectSelfManaged() && this.hasEnvironmentSearchConnection()) return envConfig; const resolved = await this.resolveServiceCredential( INSTANCE_AI_SEARCH_CREDENTIAL_POLICY, 'search', @@ -1195,9 +1333,49 @@ export class InstanceAiSettingsService { return this.enabled; } + /** Public, detail-free setup state used to gate member-facing entry points. */ + async isSetupCompleted(): Promise { + if (this.isCloud || this.aiService.isProxyEnabled()) return true; + + const [modelSelection, daytonaCredentialId, n8nSandboxCredentialId, searchCredentialId] = + await Promise.all([ + this.readAdminModelSelection(), + this.instanceCredentialBroker.getAssignedCredentialId( + INSTANCE_AI_DAYTONA_CREDENTIAL_POLICY, + ), + this.instanceCredentialBroker.getAssignedCredentialId( + INSTANCE_AI_N8N_SANDBOX_CREDENTIAL_POLICY, + ), + this.instanceCredentialBroker.getAssignedCredentialId(INSTANCE_AI_SEARCH_CREDENTIAL_POLICY), + ]); + const response = this.buildAdminSettingsResponse({ + ...modelSelection, + daytonaCredentialId, + n8nSandboxCredentialId, + searchCredentialId, + }); + const modelConfigured = Boolean( + response.modelEnvConfigured || (response.modelCredentialId && response.modelName), + ); + const sandboxCredentialId = + response.sandboxProvider === 'daytona' + ? response.daytonaCredentialId + : response.n8nSandboxCredentialId; + const sandboxConfigured = Boolean( + response.sandboxEnabled && (response.sandboxEnvConfigured || sandboxCredentialId), + ); + const searchDecided = Boolean( + response.searchEnvConfigured || response.searchCredentialId || response.searchDisabled, + ); + + return modelConfigured && sandboxConfigured && searchDecided; + } + /** Resolve just the model name (e.g. 'claude-sonnet-4-20250514') for proxy routing. */ resolveModelName(user: User): string { const prefs = this.readUserPreferences(user); + if (this.isDirectSelfManaged() && this.hasEnvironmentModelName()) + return this.extractModelName(this.config.model); const adminModelName = this.isCloud || this.aiService.isProxyEnabled() ? null : this.adminModelName; return adminModelName ?? prefs.modelName ?? this.extractModelName(this.config.model); @@ -1206,6 +1384,8 @@ export class InstanceAiSettingsService { async resolveModelConfig(user: User): Promise { const prefs = this.readUserPreferences(user); const fallbackModelName = prefs.modelName ?? this.extractModelName(this.config.model); + if (this.isDirectSelfManaged() && this.hasEnvironmentModelConnection()) + return this.envVarModelConfig(); const adminModelConfig = await this.resolveAdminModelConfig(); if (adminModelConfig) { @@ -1234,8 +1414,46 @@ export class InstanceAiSettingsService { ); } + async resolveModelConfigForVerification(user: User, modelName?: string): Promise { + const config = await this.resolveModelConfig(user); + if (!modelName) return config; + if (typeof config === 'string') { + const provider = config.includes('/') ? config.slice(0, config.indexOf('/')) : 'custom'; + return `${provider}/${modelName}`; + } + if ('id' in config && typeof config.id === 'string') { + const provider = config.id.includes('/') + ? config.id.slice(0, config.id.indexOf('/')) + : 'custom'; + return { ...config, id: `${provider}/${modelName}` }; + } + return config; + } + + buildModelConfigForConnection( + connection: InstanceAiConnectionUpdate, + modelName: string, + ): ModelConfig { + if (!INSTANCE_AI_MODEL_CREDENTIAL_POLICY.credentialTypes.includes(connection.type)) { + throw new UnprocessableRequestError( + `Connection type "${connection.type}" is not supported for the model`, + ); + } + validateModelCredential({ type: connection.type, data: connection.data }); + const config = this.buildModelConfig(connection.type, connection.data, modelName); + if (!config) { + throw new UnprocessableRequestError('The model connection is incomplete'); + } + return config; + } + private async resolveAdminModelConfig(): Promise { - if (this.isCloud || this.aiService.isProxyEnabled()) return null; + if ( + this.isCloud || + this.aiService.isProxyEnabled() || + (this.isDirectSelfManaged() && this.hasEnvironmentModelConnection()) + ) + return null; return await this.withPersistedAdminSettings(async (ctx, persisted) => { const modelName = persisted.modelName ?? null; @@ -1270,7 +1488,7 @@ export class InstanceAiSettingsService { private buildModelConfig( credentialType: string, - data: ICredentialDataDecryptedObject, + data: Record, modelName: string, ): ModelConfig | null { const provider = CREDENTIAL_TO_MODEL_PROVIDER[credentialType]; @@ -1297,7 +1515,6 @@ export class InstanceAiSettingsService { */ private static readonly MANAGED_ADMIN_FIELDS: readonly string[] = [ 'mcpServers', - 'sandboxEnabled', 'sandboxImage', 'sandboxTimeout', ]; @@ -1318,6 +1535,28 @@ export class InstanceAiSettingsService { 'modelName', ]; + private rejectEnvironmentManagedFields(update: InstanceAiAdminSettingsUpdateRequest): void { + if (!this.isDirectSelfManaged()) return; + const managedFields: string[] = []; + if (this.hasEnvironmentModelConnection()) { + managedFields.push('modelCredentialId', 'modelConnection'); + } + if (this.hasEnvironmentModelName()) managedFields.push('modelName'); + if (this.hasEnvironmentSandboxConnection()) { + managedFields.push( + 'sandboxProvider', + 'daytonaCredentialId', + 'n8nSandboxCredentialId', + 'sandboxConnection', + 'n8nSandboxServiceUrl', + ); + } + if (this.hasEnvironmentSearchConnection()) { + managedFields.push('searchCredentialId', 'searchConnection', 'searchDisabled'); + } + this.rejectManagedFields(update, managedFields, 'environment'); + } + /** Label for the deployment surface that owns the env-managed config, used in error messages. */ private deploymentLabel(): string { if (this.isCloud) return 'cloud'; @@ -1347,6 +1586,7 @@ export class InstanceAiSettingsService { const touchesSandboxSettings = update.sandboxEnabled !== undefined || update.sandboxProvider !== undefined || + update.n8nSandboxServiceUrl !== undefined || update.sandboxImage !== undefined || update.sandboxTimeout !== undefined || update.daytonaCredentialId !== undefined || @@ -1361,18 +1601,28 @@ export class InstanceAiSettingsService { this.environmentSandboxProvider, ); const sandboxEnabled = update.sandboxEnabled ?? current.sandboxEnabled ?? false; - const unavailableReason = this.getSandboxUnavailableReason(sandboxEnabled, sandboxProvider); + const sandboxServiceUrl = + this.environmentN8nSandboxServiceUrl || + update.n8nSandboxServiceUrl || + current.n8nSandboxServiceUrl || + ''; + const unavailableReason = this.getSandboxUnavailableReason( + sandboxEnabled, + sandboxProvider, + sandboxServiceUrl, + ); if (unavailableReason) throw new UnprocessableRequestError(unavailableReason); } private getSandboxUnavailableReason( sandboxEnabled: boolean, sandboxProvider: InstanceAiSandboxProvider, + sandboxServiceUrl = this.config.n8nSandboxServiceUrl, ): string | null { if ( sandboxEnabled && sandboxProvider === 'n8n-sandbox' && - this.config.n8nSandboxServiceUrl.trim().length === 0 + sandboxServiceUrl.trim().length === 0 ) { return N8N_SANDBOX_SERVICE_URL_REQUIRED_MESSAGE; } @@ -1381,7 +1631,42 @@ export class InstanceAiSettingsService { } private envVarModelConfig(): ModelConfig { - return this.envVarModelConfigForModel(this.config.model); + const configuredModel = this.config.model; + if (this.hasEnvironmentModelName() || !this.adminModelName) + return this.envVarModelConfigForModel(configuredModel); + const slash = configuredModel.indexOf('/'); + const provider = slash >= 0 ? configuredModel.slice(0, slash) : 'custom'; + return this.envVarModelConfigForModel(`${provider}/${this.adminModelName}`); + } + + private hasEnvironmentModelConnection(): boolean { + const provider = this.config.model.split('/', 1)[0] ?? ''; + const providerApiKeyEnv = MODEL_PROVIDER_API_KEY_ENV.get(provider); + return Boolean( + this.config.modelApiKey.trim() || + this.config.modelUrl.trim() || + (providerApiKeyEnv && process.env[providerApiKeyEnv]?.trim()), + ); + } + + private hasEnvironmentModelName(): boolean { + return Boolean(process.env.N8N_INSTANCE_AI_MODEL?.trim()); + } + + private hasEnvironmentSandboxConnection(): boolean { + if (this.environmentSandboxProvider === 'daytona') { + return this.aiService.isProxyEnabled() || Boolean(this.config.daytonaApiKey.trim()); + } + // n8n Sandbox accepts keyless clients, so the service URL completes the connection. + return Boolean(this.environmentN8nSandboxServiceUrl.trim()); + } + + private hasEnvironmentSearchConnection(): boolean { + return Boolean(this.config.braveSearchApiKey.trim() || this.config.searxngUrl.trim()); + } + + private isDirectSelfManaged(): boolean { + return !this.isCloud && !this.aiService.isProxyEnabled(); } private envVarModelConfigForModel(model: string): ModelConfig { @@ -1420,13 +1705,25 @@ export class InstanceAiSettingsService { this.mcpAccessEnabled = persisted.mcpAccessEnabled; if (persisted.sandboxEnabled !== undefined) c.sandboxEnabled = persisted.sandboxEnabled; this.sandboxProviderOverride = - this.isCloud || !persisted.sandboxProvider + this.isCloud || + (this.isDirectSelfManaged() && this.hasEnvironmentSandboxConnection()) || + !persisted.sandboxProvider ? undefined : normalizeSandboxProvider(persisted.sandboxProvider); c.sandboxProvider = this.sandboxProviderOverride ?? this.environmentSandboxProvider; if (persisted.sandboxImage !== undefined) c.sandboxImage = persisted.sandboxImage; if (persisted.sandboxTimeout !== undefined) c.sandboxTimeout = persisted.sandboxTimeout; if (persisted.modelName !== undefined) this.adminModelName = persisted.modelName; + if (persisted.searchDisabled !== undefined) + this.searchDisabled = + this.isDirectSelfManaged() && this.hasEnvironmentSearchConnection() + ? false + : persisted.searchDisabled; + if (persisted.n8nSandboxServiceUrl !== undefined) { + this.adminN8nSandboxServiceUrl = persisted.n8nSandboxServiceUrl; + this.config.n8nSandboxServiceUrl = + this.environmentN8nSandboxServiceUrl || persisted.n8nSandboxServiceUrl || ''; + } if (persisted.localGatewayDisabled !== undefined) c.localGatewayDisabled = persisted.localGatewayDisabled; if (persisted.browserUseEnabled !== undefined) @@ -1449,6 +1746,8 @@ export class InstanceAiSettingsService { sandboxImage: c.sandboxImage, sandboxTimeout: c.sandboxTimeout, modelName: this.adminModelName, + searchDisabled: this.searchDisabled, + n8nSandboxServiceUrl: this.adminN8nSandboxServiceUrl, localGatewayDisabled: c.localGatewayDisabled, browserUseEnabled: c.browserUseEnabled, }; diff --git a/packages/cli/src/modules/instance-ai/instance-ai-verification.service.ts b/packages/cli/src/modules/instance-ai/instance-ai-verification.service.ts new file mode 100644 index 00000000000..39f3e3048f5 --- /dev/null +++ b/packages/cli/src/modules/instance-ai/instance-ai-verification.service.ts @@ -0,0 +1,251 @@ +import type { + InstanceAiConnectionUpdate, + InstanceAiVerificationFailure, + InstanceAiVerificationResponse, + InstanceAiVerifyModelRequest, + InstanceAiVerifySandboxRequest, + InstanceAiVerifySearchRequest, +} from '@n8n/api-types'; +import { Logger } from '@n8n/backend-common'; +import { OutboundHttp } from '@n8n/backend-network'; +import { GlobalConfig } from '@n8n/config'; +import type { User } from '@n8n/db'; +import { Service } from '@n8n/di'; +import type { SandboxConfig } from '@n8n/instance-ai'; +import { ensureError } from '@n8n/utils/errors/ensure-error'; + +import { createAiProxyFetch } from '@/utils/ai-proxy-fetch'; + +import { InstanceAiModelService } from './instance-ai-model.service'; +import { InstanceAiSettingsService } from './instance-ai-settings.service'; + +const VERIFICATION_TIMEOUT_MS = 30_000; + +function numericStatus(error: unknown): number | undefined { + if (typeof error !== 'object' || error === null) return undefined; + for (const key of ['status', 'statusCode', 'code']) { + const value = Reflect.get(error, key); + if (typeof value === 'number') return value; + if (typeof value === 'string' && /^\d{3}$/.test(value)) return Number(value); + } + return undefined; +} + +function classifyFailure(error: unknown): InstanceAiVerificationFailure { + const status = numericStatus(error); + if (status === 401) return 'unauthorized'; + if (status === 403) { + const message = ensureError(error).message.toLowerCase(); + return message.includes('quota') || message.includes('limit') ? 'quota_exceeded' : 'forbidden'; + } + if (status === 429) return 'rate_limited'; + + const errorObject = ensureError(error); + if (errorObject.name === 'AbortError' || errorObject.name === 'TimeoutError') return 'timeout'; + const message = errorObject.message.toLowerCase(); + if (/\b401\b/.test(message)) return 'unauthorized'; + if (/\b403\b/.test(message)) { + return message.includes('quota') || message.includes('limit') ? 'quota_exceeded' : 'forbidden'; + } + if (/\b429\b/.test(message)) return 'rate_limited'; + if (message.includes('timeout') || message.includes('timed out')) return 'timeout'; + if ( + message.includes('econnrefused') || + message.includes('enotfound') || + message.includes('fetch failed') || + message.includes('network') + ) + return 'unreachable'; + if (message.includes('json') || message.includes('response')) return 'invalid_response'; + return 'provider_error'; +} + +function connectionString( + connection: InstanceAiConnectionUpdate | undefined, + field: string, +): string | undefined { + const value = connection?.data[field]; + return typeof value === 'string' && value.trim() ? value.trim() : undefined; +} + +@Service() +export class InstanceAiVerificationService { + constructor( + private readonly logger: Logger, + private readonly globalConfig: GlobalConfig, + private readonly settingsService: InstanceAiSettingsService, + private readonly modelService: InstanceAiModelService, + private readonly outboundHttp: OutboundHttp, + ) {} + + async verifyModel( + user: User, + request: InstanceAiVerifyModelRequest, + ): Promise { + try { + const connection = request.connection + ? await this.settingsService.resolveModelConnectionForVerification(request.connection) + : undefined; + const modelConfig = connection + ? this.settingsService.buildModelConfigForConnection(connection, request.modelName ?? '') + : request.modelName + ? await this.settingsService.resolveModelConfigForVerification(user, request.modelName) + : await this.modelService.resolveAgentModelConfig(user); + const { createModel } = await import('@n8n/agents'); + const { generateText } = await import('ai'); + const startedAt = performance.now(); + await generateText({ + model: createModel(modelConfig, createAiProxyFetch(this.outboundHttp)), + prompt: 'Reply with OK.', + maxOutputTokens: 8, + abortSignal: AbortSignal.timeout(VERIFICATION_TIMEOUT_MS), + }); + return { ok: true, latencyMs: Math.round(performance.now() - startedAt) }; + } catch (error) { + const failure = classifyFailure(error); + this.logVerificationFailure('model', failure, error); + return { ok: false, failure }; + } + } + + async verifySandbox( + user: User, + request: InstanceAiVerifySandboxRequest, + ): Promise { + const provider = request.provider ?? this.globalConfig.instanceAi.sandboxProvider; + let abortSignal: AbortSignal | undefined; + let raceWithAbort: typeof import('@n8n/agents').raceWithAbort | undefined; + let workspace: + | Awaited> + | undefined; + try { + const config = await this.resolveSandboxConfig(user, request); + abortSignal = AbortSignal.timeout(config.timeout ?? VERIFICATION_TIMEOUT_MS); + const [instanceAi, agents] = await Promise.all([ + import('@n8n/instance-ai'), + import('@n8n/agents'), + ]); + const { createSandbox, createWorkspace } = instanceAi; + raceWithAbort = agents.raceWithAbort; + const startedAt = performance.now(); + const sandbox = await raceWithAbort(async () => await createSandbox(config), abortSignal); + if (!sandbox) throw new Error('Sandbox did not start'); + const activeWorkspace = createWorkspace(sandbox); + if (!activeWorkspace) throw new Error('Sandbox workspace did not start'); + workspace = activeWorkspace; + await raceWithAbort(async () => await activeWorkspace.init(), abortSignal); + const startupMs = Math.round(performance.now() - startedAt); + const result = await raceWithAbort( + async () => + await activeWorkspace.sandbox?.executeCommand?.('printf', ['ok'], { abortSignal }), + abortSignal, + ); + if (!result || result.exitCode !== 0) throw new Error('Sandbox command failed'); + return { ok: true, startupMs }; + } catch (error) { + const classifiedFailure = classifyFailure(error); + const failure = + provider === 'daytona' && classifiedFailure === 'forbidden' + ? 'quota_exceeded' + : classifiedFailure; + this.logVerificationFailure('sandbox', failure, error, { provider }); + return { + ok: false, + failure, + }; + } finally { + if (workspace) { + const cleanup = workspace.destroy().catch((error: unknown) => { + this.logger.warn('Instance AI sandbox verification cleanup failed', { + error: ensureError(error).message, + provider, + }); + }); + if (!abortSignal || abortSignal.aborted || !raceWithAbort) { + void cleanup; + } else { + await raceWithAbort(cleanup, abortSignal).catch(() => {}); + } + } + } + } + + async verifySearch( + request: InstanceAiVerifySearchRequest, + ): Promise { + try { + const connection = request.connection + ? await this.settingsService.resolveSearchConnectionForVerification(request.connection) + : undefined; + const saved = connection ? undefined : await this.settingsService.resolveSearchConfig(); + const braveApiKey = connectionString(connection, 'apiKey') ?? saved?.braveApiKey; + const searxngUrl = connectionString(connection, 'apiUrl') ?? saved?.searxngUrl; + const { braveSearch, searxngSearch } = await import('@n8n/ai-utilities'); + const options = { + maxResults: 10, + abortSignal: AbortSignal.timeout(VERIFICATION_TIMEOUT_MS), + }; + const result = braveApiKey + ? await braveSearch(braveApiKey, 'n8n workflow automation', options) + : searxngUrl + ? await searxngSearch(searxngUrl, 'n8n workflow automation', options) + : undefined; + if (!result) throw new Error('Search provider is not configured'); + return { ok: true, resultCount: result.results.length }; + } catch (error) { + const failure = classifyFailure(error); + this.logVerificationFailure('search', failure, error); + return { ok: false, failure }; + } + } + + private logVerificationFailure( + kind: 'model' | 'sandbox' | 'search', + failure: InstanceAiVerificationFailure, + error: unknown, + context: Record = {}, + ): void { + this.logger.warn(`Instance AI ${kind} verification failed`, { + ...context, + error: ensureError(error).message, + failure, + }); + } + + private async resolveSandboxConfig( + _user: User, + request: InstanceAiVerifySandboxRequest, + ): Promise { + const instanceAi = this.globalConfig.instanceAi; + const provider = request.provider ?? instanceAi.sandboxProvider; + const connection = request.connection + ? await this.settingsService.resolveSandboxConnectionForVerification(request.connection) + : undefined; + if (provider === 'daytona') { + const saved = connection ? undefined : await this.settingsService.resolveDaytonaConfig(); + return { + enabled: true, + provider: 'daytona', + daytonaApiUrl: + connectionString(connection, 'apiUrl') ?? saved?.apiUrl ?? instanceAi.daytonaApiUrl, + daytonaApiKey: + connectionString(connection, 'apiKey') ?? saved?.apiKey ?? instanceAi.daytonaApiKey, + image: instanceAi.sandboxImage, + timeout: instanceAi.sandboxTimeout, + ephemeral: true, + }; + } + + const saved = connection ? undefined : await this.settingsService.resolveN8nSandboxConfig(); + return { + enabled: true, + provider: 'n8n-sandbox', + serviceUrl: request.serviceUrl ?? saved?.serviceUrl ?? instanceAi.n8nSandboxServiceUrl, + apiKey: + connectionString(connection, 'value') ?? + saved?.apiKey ?? + instanceAi.n8nSandboxServiceApiKey, + timeout: instanceAi.sandboxTimeout, + }; + } +} diff --git a/packages/cli/src/modules/instance-ai/instance-ai.controller.ts b/packages/cli/src/modules/instance-ai/instance-ai.controller.ts index 672c6125f92..90fa9fcf1d6 100644 --- a/packages/cli/src/modules/instance-ai/instance-ai.controller.ts +++ b/packages/cli/src/modules/instance-ai/instance-ai.controller.ts @@ -12,6 +12,9 @@ import { InstanceAiEnsureThreadRequest, InstanceAiThreadMessagesQuery, InstanceAiAdminSettingsUpdateRequest, + InstanceAiVerifyModelRequest, + InstanceAiVerifySandboxRequest, + InstanceAiVerifySearchRequest, InstanceAiUserPreferencesUpdateRequest, InstanceAiEvalExecutionRequest, InstanceAiEvalAgentExecutionRequest, @@ -28,6 +31,7 @@ import type { import { ModuleRegistry } from '@n8n/backend-common'; import { GlobalConfig } from '@n8n/config'; import { AuthenticatedRequest, User, UserRepository } from '@n8n/db'; +import { Container } from '@n8n/di'; import { RestController, GlobalScope, @@ -63,6 +67,7 @@ import { InstanceAiErrorReporterService } from './instance-ai-error-reporter.ser import { InstanceAiGatewayService } from './instance-ai-gateway.service'; import { InstanceAiMemoryService } from './instance-ai-memory.service'; import { InstanceAiSettingsService } from './instance-ai-settings.service'; +import { InstanceAiVerificationService } from './instance-ai-verification.service'; import { InstanceAiService } from './instance-ai.service'; import { CredentialsService } from '@/credentials/credentials.service'; @@ -699,6 +704,36 @@ export class InstanceAiController { return result; } + @Post('/settings/verify/model') + @GlobalScope('instanceAi:manage') + async verifyModel( + req: AuthenticatedRequest, + _res: Response, + @Body payload: InstanceAiVerifyModelRequest, + ) { + return await Container.get(InstanceAiVerificationService).verifyModel(req.user, payload); + } + + @Post('/settings/verify/sandbox') + @GlobalScope('instanceAi:manage') + async verifySandbox( + req: AuthenticatedRequest, + _res: Response, + @Body payload: InstanceAiVerifySandboxRequest, + ) { + return await Container.get(InstanceAiVerificationService).verifySandbox(req.user, payload); + } + + @Post('/settings/verify/search') + @GlobalScope('instanceAi:manage') + async verifySearch( + _req: AuthenticatedRequest, + _res: Response, + @Body payload: InstanceAiVerifySearchRequest, + ) { + return await Container.get(InstanceAiVerificationService).verifySearch(payload); + } + @OnPubSubEvent('reload-instance-ai-settings', { instanceType: 'main' }) async reloadAdminSettings() { await this.settingsService.reloadFromDb(); diff --git a/packages/cli/src/modules/instance-ai/instance-ai.module.ts b/packages/cli/src/modules/instance-ai/instance-ai.module.ts index 379a96a5c3e..0d034800dd5 100644 --- a/packages/cli/src/modules/instance-ai/instance-ai.module.ts +++ b/packages/cli/src/modules/instance-ai/instance-ai.module.ts @@ -71,12 +71,14 @@ export class InstanceAiModule implements ModuleInterface { const localGatewayDisabled = settingsService.isLocalGatewayDisabled(); const browserUseEnabled = settingsService.isBrowserUseEnabled(); const sandboxStatus = settingsService.getSandboxStatus(); + const setupCompleted = await settingsService.isSetupCompleted(); return { enabled, localGatewayDisabled, browserUseEnabled, proxyEnabled: service.isProxyEnabled(), cloudManaged: globalConfig.deployment.type === 'cloud', + setupCompleted, sandboxEnabled: sandboxStatus.enabled, workflowBuilderAvailable: enabled && sandboxStatus.workflowBuilderAvailable, sandboxUnavailableReason: sandboxStatus.unavailableReason, diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index f2a65c32093..9c53ce0107b 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -5116,6 +5116,92 @@ "settings.n8nConnect.wallet.quota": "budget", "settings.n8nConnect.wallet.topUp": "Top up balance", "settings.n8nConnect.usage.refresh.tooltip": "Refresh usage records", + "instanceAi.onboarding.title": "AI Assistant", + "instanceAi.onboarding.benefit.build": "Build and edit workflows through conversation", + "instanceAi.onboarding.benefit.debug": "Debug failed executions and suggest fixes", + "instanceAi.onboarding.benefit.help": "Ask anything about n8n and get contextual help", + "instanceAi.onboarding.incomplete.lede": "Build and edit workflows through conversation, troubleshoot failed executions, and get contextual help.", + "instanceAi.onboarding.setUp": "Set up", + "instanceAi.onboarding.connectModel": "Connect a model", + "instanceAi.onboarding.finishSetup": "Finish setup", + "instanceAi.onboarding.learnMore": "Learn more", + "instanceAi.onboarding.notSet": "Not set", + "instanceAi.onboarding.disabled": "Disabled", + "instanceAi.onboarding.foundOnServer": "Found in server configuration", + "instanceAi.onboarding.recommended": "Recommended", + "instanceAi.onboarding.existingConnection.label": "Connection", + "instanceAi.onboarding.existingConnection.current": "Current connection", + "instanceAi.onboarding.existingConnection.new": "Set up a new connection", + "instanceAi.onboarding.env.title": "These settings are managed by environment variables.", + "instanceAi.onboarding.env.description": "To edit them here, remove the environment variables and restart n8n.", + "instanceAi.onboarding.env.docs": "View configuration docs", + "instanceAi.onboarding.model.label": "Model", + "instanceAi.onboarding.model.description": "The model the Assistant thinks with.", + "instanceAi.onboarding.model.title": "Connect a model", + "instanceAi.onboarding.model.lede": "The Assistant runs on a model you pay for directly. Your prompts, workflows, and the execution data it reads go to this provider.", + "instanceAi.onboarding.model.provider": "Provider", + "instanceAi.onboarding.model.customProvider": "Self-hosted or OpenAI-compatible endpoint", + "instanceAi.onboarding.model.baseUrl": "Base URL", + "instanceAi.onboarding.model.apiKey": "API key", + "instanceAi.onboarding.model.model": "Model", + "instanceAi.onboarding.model.anthropicHint": "Opus gives the best results for complex workflows. Sonnet is faster and costs less.", + "instanceAi.onboarding.model.weakModelWarning": "Local and smaller open models can run the Assistant, but they often produce workflows that don't execute. Expect worse results than with a frontier model.", + "instanceAi.onboarding.model.success": "The credential works. The model responded in {latency} ms.", + "instanceAi.onboarding.sandbox.label": "Code sandbox", + "instanceAi.onboarding.sandbox.description": "Where the Assistant runs the code it writes.", + "instanceAi.onboarding.sandbox.title": "Add a code sandbox", + "instanceAi.onboarding.sandbox.lede": "The Assistant runs code to build and test what you ask for. It never runs on the host running n8n, so choose where the sandbox comes from.", + "instanceAi.onboarding.sandbox.freeRecommended": "Free · Recommended", + "instanceAi.onboarding.sandbox.paid": "Paid", + "instanceAi.onboarding.sandbox.n8nDescription": "Runs on your own Docker host. Nothing leaves your infrastructure.", + "instanceAi.onboarding.sandbox.daytonaDescription": "Hosted and maintained for you, set up with one API key. Your code and workflow data leave your network.", + "instanceAi.onboarding.sandbox.installDescription": "Run the sandbox service on a Docker host your n8n instance can reach, then enter its URL and key.", + "instanceAi.onboarding.sandbox.installLink": "Install instructions", + "instanceAi.onboarding.sandbox.serviceUrl": "Service URL", + "instanceAi.onboarding.sandbox.apiKey": "API key", + "instanceAi.onboarding.sandbox.apiKeyPlaceholder": "Paste the key you set on the service", + "instanceAi.onboarding.sandbox.daytonaKey": "Create a key in the", + "instanceAi.onboarding.sandbox.daytonaDashboard": "Daytona dashboard", + "instanceAi.onboarding.sandbox.success": "The sandbox works. It started in {seconds} s and ran a command successfully.", + "instanceAi.onboarding.search.label": "Web search", + "instanceAi.onboarding.search.description": "Lets the Assistant read docs and API changes.", + "instanceAi.onboarding.search.title": "Add web search", + "instanceAi.onboarding.search.lede": "Web search lets the AI Assistant read external docs and APIs instead of relying on what its model remembers.", + "instanceAi.onboarding.search.free": "Free · Recommended", + "instanceAi.onboarding.search.disable": "Disable web search", + "instanceAi.onboarding.search.searxngDescription": "Free, open-source search you host yourself. No API key required.", + "instanceAi.onboarding.search.braveDescription": "Hosted by Brave, set up with one API key. The free tier covers light use.", + "instanceAi.onboarding.search.disabledDescription": "The AI Assistant can’t search external documentation or APIs. You can enable web search later in Settings.", + "instanceAi.onboarding.search.apiKey": "API key", + "instanceAi.onboarding.search.instanceUrl": "Instance URL", + "instanceAi.onboarding.search.installDescription": "Follow the", + "instanceAi.onboarding.search.installLink": "setup instructions", + "instanceAi.onboarding.search.searxngInstallSuffix": "then enter the instance URL below.", + "instanceAi.onboarding.search.braveKeyDescription": "Generate an API key in the", + "instanceAi.onboarding.search.braveKeyLink": "Brave Search API dashboard", + "instanceAi.onboarding.search.braveKeySuffix": "then enter it below.", + "instanceAi.onboarding.search.success": "Search works. The test query came back with {count} results.", + "instanceAi.onboarding.wizard.ariaLabel": "Set up AI Assistant", + "instanceAi.onboarding.wizard.testing": "Testing…", + "instanceAi.onboarding.wizard.continue": "Continue", + "instanceAi.onboarding.wizard.apply": "Apply", + "instanceAi.onboarding.wizard.startUsing": "Start using AI Assistant", + "instanceAi.onboarding.done.title": "AI Assistant is on for everyone on this instance", + "instanceAi.onboarding.done.footnote": "You can update these options later in Settings under AI Assistant.", + "instanceAi.onboarding.verification.unauthorized": "The provider rejected the credential. Check the API key and try again.", + "instanceAi.onboarding.verification.forbidden": "The provider refused the test. Check that the credential has the required permissions and try again.", + "instanceAi.onboarding.verification.timeout": "The service didn't respond in time. Check that it's reachable and try again.", + "instanceAi.onboarding.verification.rate_limited": "The provider's rate limit was reached. Wait a minute and try again.", + "instanceAi.onboarding.verification.quota_exceeded": "The provider quota is exhausted. Add capacity or use another account, then try again.", + "instanceAi.onboarding.verification.unreachable": "The service couldn't be reached. Check the URL and network access, then try again.", + "instanceAi.onboarding.verification.invalid_response": "The service returned an unexpected response. Check that the URL points to the supported API and try again.", + "instanceAi.onboarding.verification.provider_error": "The service couldn't complete the test. Check its status and your settings, then try again.", + "instanceAi.onboarding.turnOff.action": "Turn off for this instance", + "instanceAi.onboarding.turnOff.title": "Turn off AI Assistant", + "instanceAi.onboarding.turnOff.description": "The Assistant disappears from the sidebar for everyone on this instance. You can enable AI Assistant at any time from Settings.", + "instanceAi.onboarding.turnOff.confirm": "Turn off AI Assistant", + "instanceAi.onboarding.turnOff.toastTitle": "AI Assistant turned off", + "instanceAi.onboarding.turnOff.toastDescription": "You can turn it back on at any time in Settings.", "settings.n8nAgent": "AI Assistant", "settings.n8nAgent.description": "Control how the AI Assistant runs on this instance and what data it can use.", "settings.n8nAgent.docsLabel": "AI Assistant docs", @@ -5172,7 +5258,7 @@ "settings.n8nAgent.mcpAccess.description": "Anyone on this instance can connect servers from the MCP registry to the AI Assistant.", "settings.n8nAgent.modelCredential.label": "Model", "settings.n8nAgent.modelCredential.missing.description": "No model connected. The AI Assistant won't respond until you connect one.", - "settings.n8nAgent.modelCredential.env.description": "Configured via environment variables. Connect a provider to manage it here.", + "settings.n8nAgent.modelCredential.env.description": "Managed by environment variables. Remove them and restart n8n to change the model connection here.", "settings.n8nAgent.modelCredential.env.value": "Environment configuration", "settings.n8nAgent.modelCredential.add": "Connect", "settings.n8nAgent.modelCredential.field": "Provider", @@ -5193,9 +5279,10 @@ "settings.n8nAgent.sandbox.label": "Code sandbox", "settings.n8nAgent.sandbox.set.description": "Runs and tests generated code, isolated from your instance.", "settings.n8nAgent.sandbox.missing.description": "No sandbox set. The AI Assistant won't work until you add one.", - "settings.n8nAgent.sandbox.env.description": "Configured via environment variables. Connect a provider to manage it here.", + "settings.n8nAgent.sandbox.env.description": "Managed by environment variables. Remove them and restart n8n to change the sandbox here.", "settings.n8nAgent.sandbox.env.value": "Environment configuration", "settings.n8nAgent.sandbox.add": "Add sandbox", + "settings.n8nAgent.sandbox.enable": "Enable sandbox", "settings.n8nAgent.sandboxDialog.title": "Code sandbox", "settings.n8nAgent.sandboxDialog.description": "The AI Assistant runs and tests generated code in the sandbox, isolated from your instance.", "settings.n8nAgent.sandboxCredential.apiKey": "API key", @@ -5204,7 +5291,7 @@ "settings.n8nAgent.search.label": "Web search", "settings.n8nAgent.search.recommended": "Recommended", "settings.n8nAgent.search.description": "Lets the AI Assistant research approved websites.", - "settings.n8nAgent.search.env.description": "Set via environment variables. Remove them to manage web search here.", + "settings.n8nAgent.search.env.description": "Managed by environment variables. Remove them and restart n8n to change web search here.", "settings.n8nAgent.search.env.value": "Environment configuration", "settings.n8nAgent.search.setup": "Set up", "settings.n8nAgent.searchDialog.title": "Set up web search", diff --git a/packages/frontend/editor-ui/src/app/composables/useEditorContext.ts b/packages/frontend/editor-ui/src/app/composables/useEditorContext.ts index 87abf0e8771..644dc87aa86 100644 --- a/packages/frontend/editor-ui/src/app/composables/useEditorContext.ts +++ b/packages/frontend/editor-ui/src/app/composables/useEditorContext.ts @@ -38,11 +38,13 @@ export function useEditorContext() { case 'instanceAi': // Mirrors useInstanceAiAvailable() (the feature-layer gate) with // app-layer primitives so this base composable imports no feature: - // the module is active, an admin hasn't disabled it, and the user - // may message Instance AI. + // the module is active, enabled, ready (or admin-fixable), and the + // user may message Instance AI. return ( settings.isModuleActive('instance-ai') && settings.moduleSettings['instance-ai']?.enabled !== false && + (settings.moduleSettings['instance-ai']?.setupCompleted === true || + hasPermission(['rbac'], { rbac: { scope: 'instanceAi:manage' } })) && hasPermission(['rbac'], { rbac: { scope: 'instanceAi:message' } }) ); } diff --git a/packages/frontend/editor-ui/src/app/composables/useGlobalEntityCreation.test.ts b/packages/frontend/editor-ui/src/app/composables/useGlobalEntityCreation.test.ts index 39980a500c2..833d3debb2a 100644 --- a/packages/frontend/editor-ui/src/app/composables/useGlobalEntityCreation.test.ts +++ b/packages/frontend/editor-ui/src/app/composables/useGlobalEntityCreation.test.ts @@ -619,6 +619,7 @@ describe('useGlobalEntityCreation', () => { describe('instance-ai module', () => { const INSTANCE_AI_SETTINGS = { enabled: true, + setupCompleted: true, localGatewayDisabled: false, browserUseEnabled: true, proxyEnabled: false, diff --git a/packages/frontend/editor-ui/src/app/composables/useGlobalEntityCreation.ts b/packages/frontend/editor-ui/src/app/composables/useGlobalEntityCreation.ts index 06228d14c6d..0a35c4df9a5 100644 --- a/packages/frontend/editor-ui/src/app/composables/useGlobalEntityCreation.ts +++ b/packages/frontend/editor-ui/src/app/composables/useGlobalEntityCreation.ts @@ -3,6 +3,7 @@ import { EnterpriseEditionFeature, VIEWS } from '@/app/constants'; import { AGENTS_MODULE_NAME } from '@/features/agents/constants'; import { instanceAiCreateAgentRoute } from '@/features/ai/instanceAi/createAgentRoute'; import { INSTANCE_AI_VIEW } from '@/features/ai/instanceAi/constants'; +import { useInstanceAiAvailable } from '@/features/ai/instanceAi/composables/useInstanceAiAvailability'; import { useRouter } from 'vue-router'; import { useI18n } from '@n8n/i18n'; import { sortByProperty } from '@n8n/utils/sort/sort-by-property'; @@ -18,7 +19,6 @@ import { VARIABLE_MODAL_KEY } from '@/features/settings/environments.ee/environm import { PROJECT_DATA_TABLES } from '@/features/core/dataTable/constants'; import { getResourcePermissions } from '@n8n/permissions'; import { usePageRedirectionHelper } from '@/app/composables/usePageRedirectionHelper'; -import { hasPermission } from '@/app/utils/rbac/permissions'; import type { Scope } from '@n8n/permissions'; import type { RouteLocationRaw } from 'vue-router'; import { updatedIconSet, type IconName } from '@n8n/design-system/components/N8nIcon/icons'; @@ -93,12 +93,7 @@ export const useGlobalEntityCreation = () => { const isAgentsModuleActive = computed(() => settingsStore.isModuleActive(AGENTS_MODULE_NAME)); - const isInstanceAiAvailable = computed( - () => - settingsStore.isModuleActive('instance-ai') && - settingsStore.moduleSettings['instance-ai']?.enabled !== false && - hasPermission(['rbac'], { rbac: { scope: 'instanceAi:message' } }), - ); + const isInstanceAiAvailable = useInstanceAiAvailable(); const instanceAiThreadItem = computed(() => isInstanceAiAvailable.value diff --git a/packages/frontend/editor-ui/src/app/router.test.ts b/packages/frontend/editor-ui/src/app/router.test.ts index c34f718ecbe..a19dc4ebb90 100644 --- a/packages/frontend/editor-ui/src/app/router.test.ts +++ b/packages/frontend/editor-ui/src/app/router.test.ts @@ -264,6 +264,7 @@ describe('router', () => { // Drive the `/` route's beforeEnter directly with a captured `next` instead. const instanceAiModuleSettings = { enabled: true, + setupCompleted: true, localGatewayDisabled: false, browserUseEnabled: true, proxyEnabled: false, diff --git a/packages/frontend/editor-ui/src/app/router.ts b/packages/frontend/editor-ui/src/app/router.ts index 76962664c61..e577fa3849d 100644 --- a/packages/frontend/editor-ui/src/app/router.ts +++ b/packages/frontend/editor-ui/src/app/router.ts @@ -26,7 +26,10 @@ import { RESOURCE_CENTER_EXPERIMENT, TEMPLATE_SETUP_EXPERIENCE } from '@/app/con import { useDynamicCredentials } from '@/features/resolvers/composables/useDynamicCredentials'; import { useEnvFeatureFlag } from '@/features/shared/envFeatureFlag/useEnvFeatureFlag'; import { INSTANCE_AI_VIEW } from '@/features/ai/instanceAi/constants'; -import { canMessageInstanceAi } from '@/features/ai/instanceAi/instanceAiPermissions'; +import { + canManageInstanceAi, + canMessageInstanceAi, +} from '@/features/ai/instanceAi/instanceAiPermissions'; const ChangePasswordView = async () => await import('@/features/core/auth/views/ChangePasswordView.vue'); @@ -175,9 +178,11 @@ export const routes: RouteRecordRaw[] = [ component: { render: () => null }, beforeEnter: (_to, _from, next) => { const settingsStore = useSettingsStore(); + const instanceAiSettings = settingsStore.moduleSettings['instance-ai']; if ( settingsStore.isModuleActive('instance-ai') && - settingsStore.moduleSettings['instance-ai']?.enabled !== false && + instanceAiSettings?.enabled !== false && + (instanceAiSettings?.setupCompleted === true || canManageInstanceAi()) && canMessageInstanceAi() ) { return next({ name: INSTANCE_AI_VIEW }); diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/InstanceAiView.vue b/packages/frontend/editor-ui/src/features/ai/instanceAi/InstanceAiView.vue index ea96d8dc5bd..26bc69928a7 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/InstanceAiView.vue +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/InstanceAiView.vue @@ -1,5 +1,5 @@ - - diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/settings/ConnectionFields.vue b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/settings/ConnectionFields.vue deleted file mode 100644 index 157ba76b3e8..00000000000 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/settings/ConnectionFields.vue +++ /dev/null @@ -1,54 +0,0 @@ - - - - - diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/settings/__tests__/ConnectionDialog.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/settings/__tests__/ConnectionDialog.test.ts index 6e0df346a4d..0df26996a0d 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/settings/__tests__/ConnectionDialog.test.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/settings/__tests__/ConnectionDialog.test.ts @@ -1,38 +1,10 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { fireEvent, waitFor } from '@testing-library/vue'; import { createTestingPinia } from '@pinia/testing'; +import { fireEvent, waitFor } from '@testing-library/vue'; import { setActivePinia } from 'pinia'; -import type { ICredentialType, INodeCredentialTestResult } from 'n8n-workflow'; -import { nextTick } from 'vue'; import { createComponentRenderer } from '@/__tests__/render'; -import { useSettingsStore } from '@n8n/stores/settings.store'; import { useCredentialsStore } from '@/features/credentials/credentials.store'; -import ConnectionDialog from '../ConnectionDialog.vue'; import { useInstanceAiSettingsStore } from '../../../instanceAiSettings.store'; - -// Renders ConnectionFields and CredentialInputs for real (unlike the view suite, which mocks -// ConnectionFields wholesale); only the parameter input leaf is stubbed. -vi.mock('@/features/ndv/parameters/components/ParameterInputExpanded.vue', async () => { - const { defineComponent, h } = await import('vue'); - return { - default: defineComponent({ - props: { parameter: { type: Object, required: true }, value: { default: '' } }, - emits: ['update'], - setup(props, { emit }) { - return () => - h('input', { - 'data-test-id': `param-${(props.parameter as { name: string }).name}`, - value: String(props.value ?? ''), - onInput: (event: Event) => - emit('update', { - name: (props.parameter as { name: string }).name, - value: (event.target as HTMLInputElement).value, - }), - }); - }, - }), - }; -}); +import ConnectionDialog from '../ConnectionDialog.vue'; vi.mock('@n8n/i18n', async (importOriginal) => ({ ...(await importOriginal()), @@ -40,21 +12,7 @@ vi.mock('@n8n/i18n', async (importOriginal) => ({ })); vi.mock('@/app/stores/pushConnection.store', () => ({ - usePushConnectionStore: vi.fn().mockReturnValue({ addEventListener: vi.fn() }), -})); - -vi.mock('../../../instanceAi.settings.api', () => ({ - fetchSettings: vi.fn().mockResolvedValue(null), - updateSettings: vi.fn(), - fetchPreferences: vi.fn(), - updatePreferences: vi.fn(), - fetchServiceCredentials: vi.fn().mockResolvedValue([]), - fetchInstanceModelCredentials: vi.fn().mockResolvedValue([]), -})); - -vi.mock('../../../instanceAi.api', () => ({ - createGatewayLink: vi.fn(), - getGatewayStatus: vi.fn(), + usePushConnectionStore: () => ({ addEventListener: vi.fn() }), })); vi.mock('@/app/utils/rbac/permissions', () => ({ @@ -63,67 +21,37 @@ vi.mock('@/app/utils/rbac/permissions', () => ({ const renderDialog = createComponentRenderer(ConnectionDialog); -const DAYTONA_TYPE: ICredentialType = { - name: 'daytonaApi', - displayName: 'Daytona', - properties: [ - { displayName: 'API URL', name: 'apiUrl', type: 'string', required: true, default: '' }, - { - displayName: 'API Key', - name: 'apiKey', - type: 'string', - typeOptions: { password: true }, - required: true, - default: '', - }, - ], - test: { request: { url: '/test' } }, -}; +function inputFor(element: HTMLElement): HTMLInputElement { + if (element instanceof HTMLInputElement) return element; + const input = element.querySelector('input'); + if (!input) throw new Error('Expected an input'); + return input; +} -const OPENAI_TYPE: ICredentialType = { - name: 'openAiApi', - displayName: 'OpenAI', - properties: [ - { displayName: 'API Key', name: 'apiKey', type: 'string', required: true, default: '' }, - { displayName: 'Organization ID', name: 'organizationId', type: 'string', default: '' }, - { - displayName: 'Base URL', - name: 'url', - type: 'string', - default: 'https://api.openai.com/v1', - }, - { displayName: 'Add Custom Header', name: 'header', type: 'boolean', default: false }, - { - displayName: 'Header Name', - name: 'headerName', - type: 'string', - default: '', - displayOptions: { show: { header: [true] } }, - }, - { - displayName: 'Header Value', - name: 'headerValue', - type: 'string', - default: '', - displayOptions: { show: { header: [true] } }, - }, - ], -}; - -describe('ConnectionDialog (real connection fields)', () => { +describe('ConnectionDialog', () => { let store: ReturnType; + let credentialsStore: ReturnType; beforeEach(() => { vi.clearAllMocks(); setActivePinia(createTestingPinia({ stubActions: false })); store = useInstanceAiSettingsStore(); + credentialsStore = useCredentialsStore(); + credentialsStore.setCredentialTypes([ + { name: 'anthropicApi', displayName: 'Anthropic', properties: [] }, + { name: 'openAiApi', displayName: 'OpenAI', properties: [] }, + { name: 'openRouterApi', displayName: 'OpenRouter', properties: [] }, + { name: 'daytonaApi', displayName: 'Daytona', properties: [] }, + { name: 'searXngApi', displayName: 'SearXNG', properties: [] }, + { name: 'braveSearchApi', displayName: 'Brave Search', properties: [] }, + ] as never); store.$patch({ settings: { enabled: true, permissions: {}, mcpAccessEnabled: true, sandboxEnabled: false, - sandboxProvider: 'daytona', + sandboxProvider: 'n8n-sandbox', daytonaCredentialId: null, n8nSandboxCredentialId: null, searchCredentialId: null, @@ -132,232 +60,112 @@ describe('ConnectionDialog (real connection fields)', () => { modelEnvConfigured: false, sandboxEnvConfigured: false, searchEnvConfigured: false, + searchDisabled: false, + n8nSandboxServiceUrl: null, + envManaged: { + model: { provider: false, apiKey: false, baseUrl: false, model: false }, + sandbox: { provider: false, serviceUrl: false, apiKey: false }, + search: { provider: false, apiKey: false, url: false }, + }, localGatewayDisabled: false, - }, + } as never, }); + vi.spyOn(store, 'save').mockResolvedValue(true); + vi.spyOn(store, 'refreshCredentials').mockResolvedValue(undefined); + vi.spyOn(store, 'refreshInstanceModelCredentials').mockResolvedValue(undefined); }); - it('renders an input per visible property of the selected credential type', async () => { - useCredentialsStore().setCredentialTypes([DAYTONA_TYPE]); + it('uses Cancel and Save without onboarding progress in both direct and setup contexts', async () => { + const direct = renderDialog({ props: { kind: 'search', open: true } }); - const { findByTestId, getAllByTestId, getByTestId } = renderDialog({ - props: { kind: 'sandbox', open: true }, - }); + expect(await direct.findByTestId('n8n-agent-search-dialog-cancel')).toBeVisible(); + expect(direct.getByTestId('n8n-agent-search-dialog-save')).toBeVisible(); + expect(direct.queryByTestId('n8n-agent-search-dialog-step')).toBeNull(); + expect(direct.queryByTestId('n8n-agent-search-dialog-back')).toBeNull(); + direct.unmount(); - await findByTestId('n8n-agent-sandbox-connection-fields'); - await waitFor(() => expect(getAllByTestId('credential-connection-parameter')).toHaveLength(2)); - expect(getByTestId('param-apiUrl')).toBeVisible(); - expect(getByTestId('param-apiKey')).toBeVisible(); - expect((getByTestId('param-apiUrl') as HTMLInputElement).value).toBe( - 'https://app.daytona.io/api', - ); + const setup = renderDialog({ props: { kind: 'search', open: true, setup: true } }); + expect(await setup.findByTestId('n8n-agent-search-dialog-cancel')).toBeVisible(); + expect(setup.getByTestId('n8n-agent-search-dialog-save')).toBeVisible(); + expect(setup.queryByTestId('n8n-agent-search-dialog-step')).toBeNull(); + expect(setup.queryByTestId('n8n-agent-search-dialog-back')).toBeNull(); }); - it('renders no inputs when credential types are not loaded, so the view must fetch them', async () => { - const { findByTestId, queryAllByTestId, queryByTestId } = renderDialog({ - props: { kind: 'sandbox', open: true }, - }); - - await findByTestId('n8n-agent-sandbox-provider-select'); - expect(queryByTestId('n8n-agent-sandbox-connection-fields')).toBeNull(); - expect(queryAllByTestId('credential-connection-parameter')).toHaveLength(0); - }); - - it('selects proxy-managed Daytona without requesting direct credentials', async () => { - useSettingsStore().moduleSettings = { - 'instance-ai': { - enabled: true, - localGatewayDisabled: false, - browserUseEnabled: true, - proxyEnabled: true, - cloudManaged: false, - sandboxEnabled: true, - workflowBuilderAvailable: true, - sandboxUnavailableReason: null, - runDebugEnabled: false, - }, - }; - store.$patch({ - settings: { - ...store.settings!, - sandboxProvider: 'n8n-sandbox', - sandboxEnvConfigured: true, - }, - }); - vi.mocked(store.save).mockResolvedValue(true); - const credentialsStore = useCredentialsStore(); - const { findByText, findByTestId, getByTestId, queryByTestId } = renderDialog({ - props: { kind: 'sandbox', open: true }, - }); - - const select = await findByTestId('n8n-agent-sandbox-provider-select'); - await fireEvent.click(select.querySelector('input')!); - await fireEvent.click(await findByText('Daytona')); - - expect(queryByTestId('n8n-agent-sandbox-connection-fields')).toBeNull(); - expect(getByTestId('n8n-agent-sandbox-dialog-save')).not.toBeDisabled(); - await fireEvent.click(getByTestId('n8n-agent-sandbox-dialog-save')); - - await waitFor(() => expect(store.save).toHaveBeenCalledOnce()); - expect(credentialsStore.testCredential).not.toHaveBeenCalled(); - expect(store.draft).toMatchObject({ sandboxProvider: 'daytona' }); - expect(store.draft).not.toHaveProperty('sandboxConnection'); - }); - - it('renders only the minimal OpenAI fields while preserving hidden credential data', async () => { - const credentialsStore = useCredentialsStore(); - credentialsStore.setCredentialTypes([OPENAI_TYPE]); - vi.mocked(credentialsStore.getCredentialData).mockResolvedValue({ - data: { - apiKey: 'stored-key', - organizationId: 'org-old', - url: 'https://api.openai.com/v1', - header: true, - headerName: 'x-proxy-key', - headerValue: 'old-value', - }, - } as never); - store.$patch({ - settings: { ...store.settings!, modelCredentialId: 'openai-id', modelName: 'gpt-4o' }, - instanceModelCredentials: [ - { id: 'openai-id', name: 'AI Assistant model', type: 'openAiApi' }, - ], - }); - vi.mocked(store.save).mockResolvedValue(true); - const refresh = Promise.withResolvers(); - vi.mocked(store.refreshInstanceModelCredentials).mockReturnValue(refresh.promise); - - const { emitted, findByTestId, getByTestId, queryByTestId } = renderDialog({ + it('uses the same provider and model dropdowns as onboarding', async () => { + const { findByTestId, findByText, getByTestId, queryByTestId } = renderDialog({ props: { kind: 'model', open: true }, }); - await findByTestId('param-apiKey'); - expect(getByTestId('param-url')).toBeVisible(); - expect(queryByTestId('param-organizationId')).toBeNull(); - expect(queryByTestId('param-header')).toBeNull(); - expect(queryByTestId('param-headerName')).toBeNull(); - expect(queryByTestId('param-headerValue')).toBeNull(); - await fireEvent.update(getByTestId('param-apiKey'), 'new-key'); - await fireEvent.click(getByTestId('n8n-agent-model-dialog-save')); + const provider = await findByTestId('n8n-agent-model-provider-select'); + expect(getByTestId('n8n-agent-model-name-input')).toBeVisible(); + expect(queryByTestId('assistant-model-base-url')).toBeNull(); - await waitFor(() => expect(store.save).toHaveBeenCalledOnce()); - expect(emitted().saved).toBeUndefined(); - expect(store.draft).toMatchObject({ - modelConnection: { - type: 'openAiApi', - data: { - apiKey: 'new-key', - organizationId: 'org-old', - url: 'https://api.openai.com/v1', - header: true, - headerName: 'x-proxy-key', - headerValue: 'old-value', - }, - }, - }); - refresh.resolve(); - await waitFor(() => expect(emitted().saved).toEqual([[]])); + await fireEvent.click(inputFor(provider)); + await fireEvent.click(await findByText('instanceAi.onboarding.model.customProvider')); + expect(getByTestId('assistant-model-base-url')).toBeVisible(); }); - it('hydrates the sandbox credential selected by the configured provider', async () => { - const credentialsStore = useCredentialsStore(); - vi.mocked(credentialsStore.getCredentialData).mockResolvedValue({ - data: { name: 'x-api-key', value: 'stored-key' }, - } as never); + it('does not render an empty existing-credential selector for a fresh connection', async () => { + const { findByTestId, queryByTestId } = renderDialog({ + props: { kind: 'sandbox', open: true }, + }); + + expect(await findByTestId('n8n-agent-sandbox-provider-select')).toBeVisible(); + expect(queryByTestId('n8n-agent-sandbox-existing-credential-select')).toBeNull(); + }); + + it('assigns a selected compatible credential', async () => { + store.$patch({ + instanceModelCredentials: [ + { id: 'anthropic-id', name: 'Existing Anthropic', type: 'anthropicApi' }, + { id: 'openai-id', name: 'Existing OpenAI', type: 'openAiApi' }, + ], + }); + const { emitted, findByTestId, findByText, getByTestId } = renderDialog({ + props: { kind: 'model', open: true }, + }); + + const existing = await findByTestId('n8n-agent-model-existing-credential-select'); + await fireEvent.click(inputFor(existing)); + await fireEvent.click(await findByText('Existing Anthropic · Anthropic')); + await fireEvent.click(getByTestId('n8n-agent-model-dialog-save')); + + await waitFor(() => expect(emitted().saved).toEqual([[]])); + expect(store.setField).toHaveBeenCalledWith('modelCredentialId', 'anthropic-id'); + expect(store.setField).toHaveBeenCalledWith('modelName', 'claude-opus-5'); + expect(emitted()['update:open']).toContainEqual([false]); + }); + + it('shows environment-managed settings as active and read-only', async () => { store.$patch({ settings: { ...store.settings!, - sandboxProvider: 'n8n-sandbox', - daytonaCredentialId: 'daytona-id', - n8nSandboxCredentialId: 'n8n-id', + sandboxEnvConfigured: true, + envManaged: { + ...store.settings!.envManaged, + sandbox: { provider: true, serviceUrl: true, apiKey: true }, + }, }, }); - vi.mocked(store.save).mockResolvedValue(true); - - const { findByTestId, getByTestId } = renderDialog({ + const { findByText, getByTestId, queryByTestId } = renderDialog({ props: { kind: 'sandbox', open: true }, }); - const keyField = await findByTestId('n8n-agent-sandbox-api-key-input'); - const keyInput = - keyField.tagName === 'INPUT' - ? (keyField as HTMLInputElement) - : keyField.querySelector('input')!; - expect(credentialsStore.getCredentialData).toHaveBeenCalledWith({ id: 'n8n-id' }); - expect(keyInput.value).toBe('stored-key'); - await fireEvent.update(keyInput, 'new-key'); - await fireEvent.click(getByTestId('n8n-agent-sandbox-dialog-save')); - - await waitFor(() => expect(store.save).toHaveBeenCalledOnce()); - expect(store.draft).toMatchObject({ - sandboxConnection: { - type: 'httpHeaderAuth', - data: { name: 'x-api-key', value: 'new-key' }, - }, - }); + expect(await findByText('instanceAi.onboarding.env.title')).toBeVisible(); + expect(queryByTestId('n8n-agent-sandbox-provider-select')).toBeNull(); + expect(getByTestId('n8n-agent-sandbox-dialog-save')).toBeDisabled(); }); - it('ignores a stale hydration after the dialog is reopened', async () => { - const credentialsStore = useCredentialsStore(); - credentialsStore.setCredentialTypes([OPENAI_TYPE]); - store.$patch({ - settings: { ...store.settings!, modelCredentialId: 'openai-id', modelName: 'gpt-4o' }, - instanceModelCredentials: [ - { id: 'openai-id', name: 'AI Assistant model', type: 'openAiApi' }, - ], + it('keeps setup open after save so the parent can move to the next connection', async () => { + vi.spyOn(store, 'verifySearch').mockResolvedValue({ ok: true }); + const { emitted, findByTestId, getByTestId } = renderDialog({ + props: { kind: 'search', open: true, setup: true }, }); - const stale = - Promise.withResolvers>>(); - const fresh = - Promise.withResolvers>>(); - vi.mocked(credentialsStore.getCredentialData) - .mockReturnValueOnce(stale.promise) - .mockReturnValueOnce(fresh.promise); - const result = renderDialog({ props: { kind: 'model', open: true } }); - await waitFor(() => expect(credentialsStore.getCredentialData).toHaveBeenCalledTimes(1)); - await result.rerender({ kind: 'model', open: false }); - await result.rerender({ kind: 'model', open: true }); - await waitFor(() => expect(credentialsStore.getCredentialData).toHaveBeenCalledTimes(2)); + await fireEvent.click(await findByTestId('assistant-search-disabled')); + await fireEvent.click(getByTestId('n8n-agent-search-dialog-save')); - fresh.resolve({ data: { apiKey: 'fresh-key' } } as never); - const apiKeyInput = await result.findByTestId('param-apiKey'); - expect((apiKeyInput as HTMLInputElement).value).toBe('fresh-key'); - stale.resolve({ data: { apiKey: 'stale-key' } } as never); - await stale.promise; - await nextTick(); - expect(result.getByTestId('param-apiKey')).toHaveValue('fresh-key'); - }); - - it('keeps the dialog and fields locked while testing a connection', async () => { - useCredentialsStore().setCredentialTypes([DAYTONA_TYPE]); - const credentialsStore = useCredentialsStore(); - let finishTest = (_result: INodeCredentialTestResult) => {}; - vi.mocked(credentialsStore.testCredential).mockImplementation( - async () => - await new Promise((resolve) => { - finishTest = resolve; - }), - ); - vi.mocked(store.save).mockResolvedValue(true); - - const { findByTestId, getByTestId } = renderDialog({ - props: { kind: 'sandbox', open: true }, - }); - const apiKeyInput = await findByTestId('param-apiKey'); - await fireEvent.update(apiKeyInput, 'secret'); - const saveButton = getByTestId('n8n-agent-sandbox-dialog-save'); - await waitFor(() => expect(saveButton).not.toBeDisabled()); - - await fireEvent.click(saveButton); - - const cancelButton = getByTestId('n8n-agent-sandbox-dialog-cancel'); - await waitFor(() => expect(cancelButton).toBeDisabled()); - expect(apiKeyInput).toBeDisabled(); - await fireEvent.click(cancelButton); - expect(getByTestId('n8n-agent-sandbox-dialog-cancel')).toBeDisabled(); - - finishTest({ status: 'OK', message: '' }); - await waitFor(() => expect(store.save).toHaveBeenCalled()); + await waitFor(() => expect(emitted().saved).toEqual([[]])); + expect(emitted()['update:open']).toBeUndefined(); }); }); diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/composables/useInstanceAiAvailability.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/composables/useInstanceAiAvailability.ts index 38b9bb9c019..db5c8c331e1 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/composables/useInstanceAiAvailability.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/composables/useInstanceAiAvailability.ts @@ -2,13 +2,12 @@ import { computed, type ComputedRef } from 'vue'; import { useSettingsStore } from '@n8n/stores/settings.store'; -import { canMessageInstanceAi } from '../instanceAiPermissions'; +import { canManageInstanceAi, canMessageInstanceAi } from '../instanceAiPermissions'; /** - * Whether Instance AI can be used right now: the module is active, an admin - * hasn't disabled it, and the current user has permission to message it. This - * is the canonical gate for Instance AI entry points (nav item, command bar, - * editor/credential hand-offs) — use it instead of re-deriving the three checks. + * Whether Instance AI can be used right now: the module is active, enabled, + * ready for members (or the user can finish admin setup), and the user can + * message it. This is the canonical gate for Instance AI entry points. */ export function useInstanceAiAvailable(): ComputedRef { const settingsStore = useSettingsStore(); @@ -16,6 +15,8 @@ export function useInstanceAiAvailable(): ComputedRef { () => settingsStore.isModuleActive('instance-ai') && settingsStore.moduleSettings['instance-ai']?.enabled !== false && + (settingsStore.moduleSettings['instance-ai']?.setupCompleted === true || + canManageInstanceAi()) && canMessageInstanceAi(), ); } diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/composables/useInstanceAiConfiguration.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/composables/useInstanceAiConfiguration.test.ts new file mode 100644 index 00000000000..be3e01dd557 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/composables/useInstanceAiConfiguration.test.ts @@ -0,0 +1,82 @@ +import type { InstanceAiAdminSettingsResponse } from '@n8n/api-types'; + +import { deriveInstanceAiConfiguration } from './useInstanceAiConfiguration'; + +function createSettings( + overrides: Partial = {}, +): InstanceAiAdminSettingsResponse { + return { + enabled: true, + permissions: {} as InstanceAiAdminSettingsResponse['permissions'], + mcpAccessEnabled: false, + sandboxEnabled: false, + sandboxProvider: 'n8n-sandbox', + daytonaCredentialId: null, + n8nSandboxCredentialId: null, + searchCredentialId: null, + modelCredentialId: null, + modelName: null, + modelEnvConfigured: false, + sandboxEnvConfigured: false, + searchEnvConfigured: false, + searchDisabled: false, + n8nSandboxServiceUrl: null, + envManaged: { + model: { provider: false, apiKey: false, baseUrl: false, model: false }, + sandbox: { provider: false, serviceUrl: false, apiKey: false }, + search: { provider: false, apiKey: false, url: false }, + }, + localGatewayDisabled: false, + browserUseEnabled: true, + ...overrides, + }; +} + +describe('deriveInstanceAiConfiguration', () => { + it('keeps setup incomplete until web search is explicitly decided', () => { + const settings = createSettings({ + modelCredentialId: 'model-1', + modelName: 'gpt-5.4', + sandboxEnabled: true, + n8nSandboxCredentialId: 'sandbox-1', + }); + + const undecided = deriveInstanceAiConfiguration(settings, [], []); + expect(undecided.setupCompleted).toBe(false); + expect(undecided.searchState).toBe('notset'); + + const disabled = deriveInstanceAiConfiguration({ ...settings, searchDisabled: true }, [], []); + expect(disabled.setupCompleted).toBe(true); + expect(disabled.searchState).toBe('disabled'); + }); + + it('accepts env-managed services without exposing configuration details', () => { + const configuration = deriveInstanceAiConfiguration( + createSettings({ + modelEnvConfigured: true, + sandboxEnabled: true, + sandboxEnvConfigured: true, + searchEnvConfigured: true, + }), + [], + [], + ); + + expect(configuration).toMatchObject({ + modelConfigured: true, + sandboxConfigured: true, + searchState: 'env', + setupCompleted: true, + }); + }); + + it('does not treat a configured sandbox connection as ready while sandboxing is disabled', () => { + const configuration = deriveInstanceAiConfiguration( + createSettings({ n8nSandboxCredentialId: 'sandbox-1' }), + [], + [], + ); + + expect(configuration.sandboxConfigured).toBe(false); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/composables/useInstanceAiConfiguration.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/composables/useInstanceAiConfiguration.ts new file mode 100644 index 00000000000..bbf0ce57fdb --- /dev/null +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/composables/useInstanceAiConfiguration.ts @@ -0,0 +1,29 @@ +import { computed } from 'vue'; + +import { useInstanceAiSettingsStore } from '../instanceAiSettings.store'; +import { deriveInstanceAiConfiguration } from '../instanceAiConfiguration'; + +export { deriveInstanceAiConfiguration } from '../instanceAiConfiguration'; +export type { InstanceAiSearchState } from '../instanceAiConfiguration'; + +export function useInstanceAiConfiguration() { + const store = useInstanceAiSettingsStore(); + const configuration = computed(() => + deriveInstanceAiConfiguration( + store.settings, + store.instanceModelCredentials, + store.serviceCredentials, + ), + ); + + return { + modelCredential: computed(() => configuration.value.modelCredential), + modelConfigured: computed(() => configuration.value.modelConfigured), + sandboxCredentialId: computed(() => configuration.value.sandboxCredentialId), + sandboxConfigured: computed(() => configuration.value.sandboxConfigured), + searchCredential: computed(() => configuration.value.searchCredential), + searchState: computed(() => configuration.value.searchState), + setupCompleted: computed(() => configuration.value.setupCompleted), + hasSetupProgress: computed(() => configuration.value.hasSetupProgress), + }; +} diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/composables/useInstanceAiSetupSteps.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/composables/useInstanceAiSetupSteps.ts deleted file mode 100644 index 8bb181c72e0..00000000000 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/composables/useInstanceAiSetupSteps.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { computed } from 'vue'; -import { useI18n } from '@n8n/i18n'; -import { useInstanceAiSettingsStore } from '../instanceAiSettings.store'; - -/** Step labels for the setup wizard; the optional search step drops off when already configured. */ -export function useInstanceAiSetupSteps(step: number) { - const i18n = useI18n(); - const store = useInstanceAiSettingsStore(); - - const totalSteps = computed(() => - (store.settings?.searchCredentialId ?? store.settings?.searchEnvConfigured) ? 2 : 3, - ); - const stepLabel = computed(() => - i18n.baseText('settings.n8nAgent.setup.step', { - interpolate: { step, total: totalSteps.value }, - }), - ); - const isLastStep = computed(() => step >= totalSteps.value); - - return { stepLabel, isLastStep }; -} diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/instanceAi.settings.api.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/instanceAi.settings.api.ts index 8bf7dacd968..6b1bf113851 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/instanceAi.settings.api.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/instanceAi.settings.api.ts @@ -6,6 +6,10 @@ import type { InstanceAiUserPreferencesResponse, InstanceAiUserPreferencesUpdateRequest, InstanceAiProviderConnection, + InstanceAiVerificationResponse, + InstanceAiVerifyModelRequest, + InstanceAiVerifySandboxRequest, + InstanceAiVerifySearchRequest, } from '@n8n/api-types'; export async function fetchSettings( @@ -45,3 +49,24 @@ export async function fetchInstanceModelCredentials( ): Promise { return await makeRestApiRequest(context, 'GET', '/instance-ai/settings/model-credentials'); } + +export async function verifyModel( + context: IRestApiContext, + body: InstanceAiVerifyModelRequest, +): Promise { + return await makeRestApiRequest(context, 'POST', '/instance-ai/settings/verify/model', body); +} + +export async function verifySandbox( + context: IRestApiContext, + body: InstanceAiVerifySandboxRequest, +): Promise { + return await makeRestApiRequest(context, 'POST', '/instance-ai/settings/verify/sandbox', body); +} + +export async function verifySearch( + context: IRestApiContext, + body: InstanceAiVerifySearchRequest, +): Promise { + return await makeRestApiRequest(context, 'POST', '/instance-ai/settings/verify/search', body); +} diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/instanceAiConfiguration.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/instanceAiConfiguration.ts new file mode 100644 index 00000000000..8e665a92fdc --- /dev/null +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/instanceAiConfiguration.ts @@ -0,0 +1,42 @@ +import type { InstanceAiAdminSettingsResponse, InstanceAiProviderConnection } from '@n8n/api-types'; + +export type InstanceAiSearchState = 'set' | 'env' | 'disabled' | 'notset'; + +export function deriveInstanceAiConfiguration( + settings: InstanceAiAdminSettingsResponse | null, + modelCredentials: InstanceAiProviderConnection[], + serviceCredentials: InstanceAiProviderConnection[], +) { + const modelCredential = modelCredentials.find( + (credential) => credential.id === settings?.modelCredentialId, + ); + const modelConfigured = Boolean( + settings?.modelEnvConfigured || (settings?.modelCredentialId && settings.modelName), + ); + const sandboxCredentialId = + settings?.sandboxProvider === 'daytona' + ? settings.daytonaCredentialId + : settings?.n8nSandboxCredentialId; + const sandboxConfigured = Boolean( + settings?.sandboxEnabled && (sandboxCredentialId || settings.sandboxEnvConfigured), + ); + const searchCredential = serviceCredentials.find( + (credential) => credential.id === settings?.searchCredentialId, + ); + let searchState: InstanceAiSearchState = 'notset'; + if (settings?.searchCredentialId) searchState = 'set'; + else if (settings?.searchEnvConfigured) searchState = 'env'; + else if (settings?.searchDisabled) searchState = 'disabled'; + + const setupCompleted = modelConfigured && sandboxConfigured && searchState !== 'notset'; + return { + modelCredential, + modelConfigured, + sandboxCredentialId, + sandboxConfigured, + searchCredential, + searchState, + setupCompleted, + hasSetupProgress: modelConfigured || sandboxConfigured || searchState !== 'notset', + }; +} diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/instanceAiConnection.constants.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/instanceAiConnection.constants.ts new file mode 100644 index 00000000000..d2d7c007336 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/instanceAiConnection.constants.ts @@ -0,0 +1,75 @@ +import type { + INSTANCE_AI_MODEL_CREDENTIAL_TYPES, + INSTANCE_AI_SEARCH_CREDENTIAL_TYPES, + InstanceAiSandboxProvider, +} from '@n8n/api-types'; + +import { SANDBOX_PROVIDER_LABELS } from './constants'; + +export type InstanceAiModelProvider = 'anthropic' | 'openai' | 'openrouter' | 'custom'; +export type InstanceAiSearchProvider = 'searxng' | 'brave' | 'disabled'; + +export const INSTANCE_AI_MODEL_PROVIDERS = [ + { + id: 'anthropic', + credentialType: 'anthropicApi', + label: 'Anthropic', + models: ['claude-opus-5', 'claude-sonnet-5'], + placeholder: 'sk-ant-…', + }, + { + id: 'openai', + credentialType: 'openAiApi', + label: 'OpenAI', + models: ['gpt-5.6-sol', 'gpt-5.6-terra'], + placeholder: 'sk-…', + }, + { + id: 'openrouter', + credentialType: 'openRouterApi', + label: 'OpenRouter', + models: [ + 'anthropic/claude-opus-5', + 'anthropic/claude-sonnet-5', + 'openai/gpt-5.6-sol', + 'openai/gpt-5.6-terra', + 'moonshotai/kimi-k3', + ], + placeholder: 'sk-or-…', + }, + { + id: 'custom', + credentialType: 'openAiApi', + label: null, + models: [], + placeholder: 'Leave empty for Ollama', + }, +] as const satisfies ReadonlyArray<{ + id: InstanceAiModelProvider; + credentialType: (typeof INSTANCE_AI_MODEL_CREDENTIAL_TYPES)[number]; + label: string | null; + models: readonly string[]; + placeholder: string; +}>; + +export const INSTANCE_AI_SANDBOX_PROVIDERS = [ + { + id: 'n8n-sandbox', + label: SANDBOX_PROVIDER_LABELS['n8n-sandbox'], + onboardingLabel: 'n8n Sandbox', + }, + { id: 'daytona', label: SANDBOX_PROVIDER_LABELS.daytona, onboardingLabel: 'Daytona' }, +] as const satisfies ReadonlyArray<{ + id: InstanceAiSandboxProvider; + label: string; + onboardingLabel: string; +}>; + +export const INSTANCE_AI_SEARCH_PROVIDERS = [ + { id: 'searxng', credentialType: 'searXngApi', label: 'SearXNG' }, + { id: 'brave', credentialType: 'braveSearchApi', label: 'Brave Search' }, +] as const satisfies ReadonlyArray<{ + id: Exclude; + credentialType: (typeof INSTANCE_AI_SEARCH_CREDENTIAL_TYPES)[number]; + label: string; +}>; diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/instanceAiSettings.store.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/instanceAiSettings.store.ts index 70be86710b1..9532260edc1 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/instanceAiSettings.store.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/instanceAiSettings.store.ts @@ -11,6 +11,9 @@ import { updatePreferences, fetchServiceCredentials, fetchInstanceModelCredentials, + verifyModel as verifyModelRequest, + verifySandbox as verifySandboxRequest, + verifySearch as verifySearchRequest, } from './instanceAi.settings.api'; import { hasPermission } from '@/app/utils/rbac/permissions'; import { @@ -30,6 +33,10 @@ import type { InstanceAiPermissions, InstanceAiPermissionMode, ToolCategory, + InstanceAiVerifyModelRequest, + InstanceAiVerifySandboxRequest, + InstanceAiVerifySearchRequest, + InstanceAiVerificationResponse, } from '@n8n/api-types'; import { i18n } from '@n8n/i18n'; import { @@ -38,6 +45,7 @@ import { type BrowserUseConnectionType, type ComputerUseConnectionType, } from './constants'; +import { deriveInstanceAiConfiguration } from './instanceAiConfiguration'; export const useInstanceAiSettingsStore = defineStore('instanceAiSettings', () => { const rootStore = useRootStore(); @@ -126,12 +134,18 @@ export const useInstanceAiSettingsStore = defineStore('instanceAiSettings', () = ): void { const ms = settingsStore.moduleSettings; const prev = ms['instance-ai']; + const configuration = deriveInstanceAiConfiguration( + adminRes, + instanceModelCredentials.value, + serviceCredentials.value, + ); const merged: NonNullable = { enabled: adminRes.enabled, localGatewayDisabled: adminRes.localGatewayDisabled ?? prev?.localGatewayDisabled ?? false, browserUseEnabled: adminRes.browserUseEnabled ?? prev?.browserUseEnabled ?? true, proxyEnabled: prev?.proxyEnabled ?? false, cloudManaged: prev?.cloudManaged ?? false, + setupCompleted: configuration.setupCompleted, sandboxEnabled: adminRes.sandboxEnabled, workflowBuilderAvailable: adminRes.sandboxEnabled ? (prev?.workflowBuilderAvailable ?? true) @@ -194,7 +208,7 @@ export const useInstanceAiSettingsStore = defineStore('instanceAiSettings', () = * Persists the staged admin draft. Returns whether the save succeeded; on * failure the draft is discarded so a later unrelated save can't flush it. */ - async function save(): Promise { + async function save(showToast = true): Promise { if (Object.keys(draft).length === 0) return true; isSaving.value = true; try { @@ -203,10 +217,12 @@ export const useInstanceAiSettingsStore = defineStore('instanceAiSettings', () = } as InstanceAiAdminSettingsUpdateRequest); settings.value = result; clearDraft(); - toast.showMessage({ - title: i18n.baseText('settings.n8nAgent.toast.saved'), - type: 'success', - }); + if (showToast) { + toast.showMessage({ + title: i18n.baseText('settings.n8nAgent.toast.saved'), + type: 'success', + }); + } syncInstanceAiFlagIntoGlobalModuleSettings(result); await settingsStore.getModuleSettings().catch(() => {}); return true; @@ -220,7 +236,7 @@ export const useInstanceAiSettingsStore = defineStore('instanceAiSettings', () = } /** Persists only the Instance AI on/off flag (does not send other admin draft fields). */ - async function persistEnabled(value: boolean): Promise { + async function persistEnabled(value: boolean, showToast = true): Promise { isSaving.value = true; try { const result = await updateSettings(rootStore.restApiContext, { enabled: value }); @@ -228,10 +244,12 @@ export const useInstanceAiSettingsStore = defineStore('instanceAiSettings', () = delete draft.enabled; syncInstanceAiFlagIntoGlobalModuleSettings(result); await settingsStore.getModuleSettings().catch(() => {}); - toast.showMessage({ - title: i18n.baseText('settings.n8nAgent.toast.saved'), - type: 'success', - }); + if (showToast) { + toast.showMessage({ + title: i18n.baseText('settings.n8nAgent.toast.saved'), + type: 'success', + }); + } return true; } catch { toast.showError( @@ -598,6 +616,24 @@ export const useInstanceAiSettingsStore = defineStore('instanceAiSettings', () = await Promise.all(promises); } + async function verifyModel( + payload: InstanceAiVerifyModelRequest, + ): Promise { + return await verifyModelRequest(rootStore.restApiContext, payload); + } + + async function verifySandbox( + payload: InstanceAiVerifySandboxRequest, + ): Promise { + return await verifySandboxRequest(rootStore.restApiContext, payload); + } + + async function verifySearch( + payload: InstanceAiVerifySearchRequest, + ): Promise { + return await verifySearchRequest(rootStore.restApiContext, payload); + } + return { canManage, canManageAiUsage, @@ -646,6 +682,9 @@ export const useInstanceAiSettingsStore = defineStore('instanceAiSettings', () = refreshCredentials, refreshInstanceModelCredentials, refreshModuleSettings, + verifyModel, + verifySandbox, + verifySearch, // Browser Use (direct channel) browserConnected, browserConnectedAt, diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/module.descriptor.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/module.descriptor.ts index 836b4d0d27d..d367afcb3a3 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/module.descriptor.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/module.descriptor.ts @@ -36,6 +36,7 @@ export const InstanceAiModule: FrontendModuleDescription = { { path: '/assistant', component: InstanceAiView, + beforeEnter: () => (useInstanceAiAvailable().value ? true : { name: VIEWS.HOMEPAGE }), meta: { layout: 'instanceAi', middleware: ['authenticated', 'custom'], diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/onboarding/InstanceAiOnboardingIntro.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/onboarding/InstanceAiOnboardingIntro.test.ts new file mode 100644 index 00000000000..552e30d5e07 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/onboarding/InstanceAiOnboardingIntro.test.ts @@ -0,0 +1,76 @@ +import { fireEvent } from '@testing-library/vue'; + +import { createComponentRenderer } from '@/__tests__/render'; + +import InstanceAiOnboardingIntro from './InstanceAiOnboardingIntro.vue'; + +const DOCS_URL = 'https://docs.n8n.io/build/ways-of-building-workflows/ai-assistant'; + +vi.mock('@n8n/i18n', async (importOriginal) => ({ + ...(await importOriginal()), + useI18n: () => ({ baseText: (key: string) => key }), +})); + +const renderIntro = createComponentRenderer(InstanceAiOnboardingIntro, { + props: { + incomplete: false, + connectModelOnly: false, + modelValue: 'Not set', + sandboxValue: 'Not set', + searchValue: 'Not set', + }, +}); + +describe('InstanceAiOnboardingIntro', () => { + it('renders the initial benefits and emits its actions', async () => { + const { emitted, getByTestId, getByText } = renderIntro(); + + expect(getByTestId('assistant-setup-intro')).toBeVisible(); + expect(getByText('instanceAi.onboarding.benefit.build')).toBeVisible(); + expect(getByText('instanceAi.onboarding.setUp')).toBeVisible(); + + await fireEvent.click(getByTestId('assistant-setup-cta')); + await fireEvent.click(getByTestId('assistant-turn-off')); + + expect(emitted().setup).toEqual([[]]); + expect(emitted().turnOff).toEqual([[]]); + }); + + it('uses the connect-model CTA for the compose fast path', () => { + const { getByText } = renderIntro({ props: { connectModelOnly: true } }); + + expect(getByText('instanceAi.onboarding.connectModel')).toBeVisible(); + }); + + it('renders setup progress and opens the selected checklist step', async () => { + const { emitted, getByTestId, getByText } = renderIntro({ + props: { + incomplete: true, + modelValue: 'anthropic/claude-opus-5', + sandboxValue: 'n8n Sandbox', + searchValue: 'Disabled', + }, + }); + + expect(getByTestId('assistant-setup-incomplete')).toBeVisible(); + expect(getByTestId('settings-row-group')).toBeVisible(); + expect(getByText('instanceAi.onboarding.incomplete.lede')).toBeVisible(); + expect(getByText('anthropic/claude-opus-5')).toBeVisible(); + expect(getByText('n8n Sandbox')).toBeVisible(); + expect(getByText('Disabled')).toBeVisible(); + + await fireEvent.click(getByTestId('assistant-setup-checklist-model')); + await fireEvent.click(getByTestId('assistant-setup-checklist-sandbox')); + await fireEvent.click(getByTestId('assistant-setup-checklist-search')); + const finishSetup = getByTestId('assistant-finish-setup-cta'); + const learnMore = getByTestId('assistant-learn-more'); + expect( + Boolean(finishSetup.compareDocumentPosition(learnMore) & Node.DOCUMENT_POSITION_FOLLOWING), + ).toBe(true); + expect(learnMore).toHaveAttribute('href', DOCS_URL); + await fireEvent.click(finishSetup); + + expect(emitted().openStep).toEqual([['model'], ['sandbox'], ['search']]); + expect(emitted().setup).toEqual([[]]); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/onboarding/InstanceAiOnboardingIntro.vue b/packages/frontend/editor-ui/src/features/ai/instanceAi/onboarding/InstanceAiOnboardingIntro.vue new file mode 100644 index 00000000000..df3f9b72009 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/onboarding/InstanceAiOnboardingIntro.vue @@ -0,0 +1,229 @@ + + + + + diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/onboarding/InstanceAiOnboardingView.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/onboarding/InstanceAiOnboardingView.test.ts new file mode 100644 index 00000000000..221df270885 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/onboarding/InstanceAiOnboardingView.test.ts @@ -0,0 +1,296 @@ +import { createTestingPinia } from '@pinia/testing'; +import { fireEvent, waitFor } from '@testing-library/vue'; +import { setActivePinia } from 'pinia'; +import { defineComponent } from 'vue'; + +import { createComponentRenderer } from '@/__tests__/render'; +import { MODAL_CONFIRM, VIEWS } from '@/app/constants'; +import { useCredentialsStore } from '@/features/credentials/credentials.store'; +import { useInstanceAiSettingsStore } from '../instanceAiSettings.store'; + +import InstanceAiOnboardingView from './InstanceAiOnboardingView.vue'; + +const confirmMock = vi.hoisted(() => vi.fn()); +const showMessageMock = vi.hoisted(() => vi.fn()); +const routerPushMock = vi.hoisted(() => vi.fn()); + +vi.mock('@n8n/i18n', async (importOriginal) => ({ + ...(await importOriginal()), + useI18n: () => ({ baseText: (key: string) => key }), +})); + +vi.mock('@/app/composables/useMessage', () => ({ + useMessage: () => ({ confirm: confirmMock }), +})); + +vi.mock('@n8n/composables/useToast', () => ({ + useToast: () => ({ showMessage: showMessageMock }), +})); + +vi.mock('vue-router', async (importOriginal) => ({ + ...(await importOriginal()), + useRouter: () => ({ push: routerPushMock }), +})); + +vi.mock('@/app/stores/pushConnection.store', () => ({ + usePushConnectionStore: () => ({ addEventListener: vi.fn() }), +})); + +const IntroStub = defineComponent({ + name: 'InstanceAiOnboardingIntro', + props: { + incomplete: Boolean, + connectModelOnly: Boolean, + modelValue: String, + sandboxValue: String, + searchValue: String, + }, + emits: ['setup', 'openStep', 'turnOff'], + template: ` +
+
+ `, +}); + +const WizardStub = defineComponent({ + name: 'InstanceAiOnboardingWizard', + props: { + open: Boolean, + step: String, + editMode: Boolean, + composeFastPath: Boolean, + }, + emits: ['update:open', 'advance', 'back', 'edit', 'completed'], + template: ` +
+
+ `, +}); + +const renderView = createComponentRenderer(InstanceAiOnboardingView, { + global: { + stubs: { + InstanceAiOnboardingIntro: IntroStub, + InstanceAiOnboardingWizard: WizardStub, + }, + }, +}); + +function setupStore(overrides: Record = {}) { + const pinia = createTestingPinia(); + setActivePinia(pinia); + const store = useInstanceAiSettingsStore(); + store.$patch({ + settings: { + enabled: true, + permissions: {}, + mcpAccessEnabled: true, + sandboxEnabled: false, + sandboxProvider: 'n8n-sandbox', + daytonaCredentialId: null, + n8nSandboxCredentialId: null, + searchCredentialId: null, + modelCredentialId: null, + modelName: null, + modelEnvConfigured: false, + sandboxEnvConfigured: false, + searchEnvConfigured: false, + searchDisabled: false, + n8nSandboxServiceUrl: null, + envManaged: { + model: { provider: false, apiKey: false, baseUrl: false, model: false }, + sandbox: { provider: false, serviceUrl: false, apiKey: false }, + search: { provider: false, apiKey: false, url: false }, + }, + localGatewayDisabled: false, + ...overrides, + } as never, + }); + vi.mocked(store.fetch).mockResolvedValue(undefined); + vi.mocked(store.persistEnabled).mockResolvedValue(true); + const credentialsStore = useCredentialsStore(); + vi.mocked(credentialsStore.fetchCredentialTypes).mockResolvedValue(undefined as never); + return { pinia, store, credentialsStore }; +} + +describe('InstanceAiOnboardingView', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('fetches setup data and opens at the first unmet step', async () => { + const { pinia, store, credentialsStore } = setupStore(); + const { getByTestId } = renderView({ pinia }); + + await waitFor(() => expect(store.fetch).toHaveBeenCalled()); + expect(credentialsStore.fetchCredentialTypes).toHaveBeenCalledWith(false); + expect(getByTestId('intro-stub')).toHaveAttribute('data-incomplete', 'false'); + + await fireEvent.click(getByTestId('intro-setup')); + expect(getByTestId('wizard-stub')).toHaveAttribute('data-open', 'true'); + expect(getByTestId('wizard-stub')).toHaveAttribute('data-step', 'model'); + }); + + it('shows saved setup progress and opens a selected checklist step', async () => { + const { pinia, store } = setupStore({ + modelCredentialId: 'model-credential', + modelName: 'claude-opus-5', + }); + store.$patch({ + instanceModelCredentials: [ + { id: 'model-credential', name: 'Anthropic', type: 'anthropicApi' }, + ], + }); + const { getByTestId } = renderView({ pinia }); + + expect(getByTestId('intro-stub')).toHaveAttribute('data-incomplete', 'true'); + expect(getByTestId('intro-stub')).toHaveAttribute( + 'data-model-value', + 'anthropic/claude-opus-5', + ); + + await fireEvent.click(getByTestId('intro-open-search')); + expect(getByTestId('wizard-stub')).toHaveAttribute('data-step', 'search'); + expect(getByTestId('wizard-stub')).toHaveAttribute('data-open', 'true'); + expect(getByTestId('wizard-stub')).toHaveAttribute('data-edit-mode', 'true'); + }); + + it('uses the model-only fast path when sandbox and search are server-managed', async () => { + const { pinia } = setupStore({ + sandboxEnabled: true, + sandboxEnvConfigured: true, + searchEnvConfigured: true, + }); + const { getByTestId } = renderView({ pinia }); + + expect(getByTestId('intro-stub')).toHaveAttribute('data-connect-model-only', 'true'); + expect(getByTestId('intro-stub')).toHaveAttribute( + 'data-sandbox-value', + 'instanceAi.onboarding.foundOnServer', + ); + expect(getByTestId('intro-stub')).toHaveAttribute( + 'data-search-value', + 'instanceAi.onboarding.foundOnServer', + ); + }); + + it('requires the sandbox step when server configuration is present but disabled', async () => { + const { pinia } = setupStore({ + modelEnvConfigured: true, + sandboxEnabled: false, + sandboxEnvConfigured: true, + searchEnvConfigured: true, + }); + const { getByTestId } = renderView({ pinia }); + + expect(getByTestId('intro-stub')).toHaveAttribute('data-connect-model-only', 'false'); + + await fireEvent.click(getByTestId('intro-setup')); + expect(getByTestId('wizard-stub')).toHaveAttribute('data-step', 'sandbox'); + }); + + it('labels configured Daytona and Brave connections and opens summary edits', async () => { + const { pinia, store } = setupStore({ + modelCredentialId: 'model-credential', + modelName: 'custom-model', + sandboxEnabled: true, + sandboxProvider: 'daytona', + daytonaCredentialId: 'sandbox-credential', + searchCredentialId: 'search-credential', + }); + store.$patch({ + instanceModelCredentials: [ + { id: 'model-credential', name: 'Custom', type: 'customCredential' }, + ], + serviceCredentials: [{ id: 'search-credential', name: 'Brave', type: 'braveSearchApi' }], + }); + const { getByTestId } = renderView({ pinia }); + + expect(getByTestId('intro-stub')).toHaveAttribute('data-model-value', 'custom-model'); + expect(getByTestId('intro-stub')).toHaveAttribute('data-sandbox-value', 'Daytona'); + expect(getByTestId('intro-stub')).toHaveAttribute('data-search-value', 'Brave Search'); + + await fireEvent.click(getByTestId('intro-setup')); + await fireEvent.click(getByTestId('wizard-edit-sandbox')); + expect(getByTestId('wizard-stub')).toHaveAttribute('data-step', 'sandbox'); + expect(getByTestId('wizard-stub')).toHaveAttribute('data-edit-mode', 'true'); + await fireEvent.click(getByTestId('wizard-open')); + expect(getByTestId('wizard-stub')).toHaveAttribute('data-open', 'true'); + }); + + it('emits completion only when setup is actually complete', async () => { + const incomplete = setupStore({ modelEnvConfigured: true }); + const incompleteView = renderView({ pinia: incomplete.pinia }); + await fireEvent.click(incompleteView.getByTestId('intro-setup')); + await fireEvent.click(incompleteView.getByTestId('wizard-complete')); + expect(incompleteView.emitted().completed).toBeUndefined(); + await fireEvent.click(incompleteView.getByTestId('wizard-close')); + expect(incompleteView.emitted().completed).toBeUndefined(); + expect(incompleteView.getByTestId('wizard-stub')).toHaveAttribute('data-open', 'false'); + incompleteView.unmount(); + + const complete = setupStore({ + modelEnvConfigured: true, + sandboxEnabled: true, + sandboxEnvConfigured: true, + searchDisabled: true, + }); + const completeView = renderView({ pinia: complete.pinia }); + await fireEvent.click(completeView.getByTestId('intro-setup')); + await fireEvent.click(completeView.getByTestId('wizard-close')); + expect(completeView.emitted().completed).toEqual([[]]); + expect(completeView.getByTestId('wizard-stub')).toHaveAttribute('data-open', 'false'); + }); + + it('keeps the assistant enabled when turn-off is cancelled', async () => { + const { pinia, store } = setupStore(); + confirmMock.mockResolvedValue('cancel'); + const { getByTestId } = renderView({ pinia }); + + await fireEvent.click(getByTestId('intro-turn-off')); + + await waitFor(() => expect(confirmMock).toHaveBeenCalled()); + expect(store.persistEnabled).not.toHaveBeenCalled(); + expect(routerPushMock).not.toHaveBeenCalled(); + }); + + it('turns off the assistant, confirms with a toast, and returns home', async () => { + const { pinia, store } = setupStore(); + confirmMock.mockResolvedValue(MODAL_CONFIRM); + vi.mocked(store.persistEnabled).mockResolvedValue(true); + const { getByTestId } = renderView({ pinia }); + + await fireEvent.click(getByTestId('intro-turn-off')); + + await waitFor(() => expect(store.persistEnabled).toHaveBeenCalledWith(false, false)); + expect(showMessageMock).toHaveBeenCalledWith({ + title: 'instanceAi.onboarding.turnOff.toastTitle', + message: 'instanceAi.onboarding.turnOff.toastDescription', + type: 'success', + }); + expect(routerPushMock).toHaveBeenCalledWith({ name: VIEWS.HOMEPAGE }); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/onboarding/InstanceAiOnboardingView.vue b/packages/frontend/editor-ui/src/features/ai/instanceAi/onboarding/InstanceAiOnboardingView.vue new file mode 100644 index 00000000000..70e70749fbd --- /dev/null +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/onboarding/InstanceAiOnboardingView.vue @@ -0,0 +1,166 @@ + + + + + diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/onboarding/InstanceAiOnboardingWizard.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/onboarding/InstanceAiOnboardingWizard.test.ts new file mode 100644 index 00000000000..b86c139e186 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/onboarding/InstanceAiOnboardingWizard.test.ts @@ -0,0 +1,527 @@ +import { createTestingPinia } from '@pinia/testing'; +import { fireEvent, waitFor } from '@testing-library/vue'; +import { setActivePinia } from 'pinia'; + +import { createComponentRenderer } from '@/__tests__/render'; +import { useCredentialsStore } from '@/features/credentials/credentials.store'; +import { useInstanceAiSettingsStore } from '../instanceAiSettings.store'; + +import InstanceAiOnboardingWizard from './InstanceAiOnboardingWizard.vue'; + +vi.mock('@n8n/i18n', async (importOriginal) => ({ + ...(await importOriginal()), + useI18n: () => ({ baseText: (key: string) => key }), +})); + +vi.mock('@/app/stores/pushConnection.store', () => ({ + usePushConnectionStore: () => ({ addEventListener: vi.fn() }), +})); + +vi.mock('@/app/utils/rbac/permissions', () => ({ + hasPermission: vi.fn().mockReturnValue(true), +})); + +const renderWizard = createComponentRenderer(InstanceAiOnboardingWizard, { + props: { + open: true, + step: 'model', + editMode: false, + sequence: ['model', 'sandbox', 'search', 'done'], + modelValue: 'anthropic/claude-opus-5', + sandboxValue: 'n8n Sandbox', + searchValue: 'Disabled', + composeFastPath: false, + }, +}); + +function inputFor(element: HTMLElement): HTMLInputElement { + if (element instanceof HTMLInputElement) return element; + const input = element.querySelector('input'); + if (!input) throw new Error('Expected an input'); + return input; +} + +function setupStore(overrides: Record = {}) { + const pinia = createTestingPinia(); + setActivePinia(pinia); + const store = useInstanceAiSettingsStore(); + store.$patch({ + settings: { + enabled: true, + permissions: {}, + mcpAccessEnabled: true, + sandboxEnabled: false, + sandboxProvider: 'n8n-sandbox', + daytonaCredentialId: null, + n8nSandboxCredentialId: null, + searchCredentialId: null, + modelCredentialId: null, + modelName: null, + modelEnvConfigured: false, + sandboxEnvConfigured: false, + searchEnvConfigured: false, + searchDisabled: false, + n8nSandboxServiceUrl: null, + envManaged: { + model: { provider: false, apiKey: false, baseUrl: false, model: false }, + sandbox: { provider: false, serviceUrl: false, apiKey: false }, + search: { provider: false, apiKey: false, url: false }, + }, + localGatewayDisabled: false, + ...overrides, + } as never, + }); + vi.mocked(store.save).mockResolvedValue(true); + vi.mocked(store.refreshCredentials).mockResolvedValue(undefined); + vi.mocked(store.refreshInstanceModelCredentials).mockResolvedValue(undefined); + return { pinia, store, credentialsStore: useCredentialsStore() }; +} + +describe('InstanceAiOnboardingWizard', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('verifies and saves a model connection before advancing', async () => { + const { pinia, store } = setupStore(); + vi.mocked(store.verifyModel).mockResolvedValue({ ok: true, latencyMs: 125 }); + const { emitted, findByTestId, findByText, getByTestId } = renderWizard({ pinia }); + const primary = await findByTestId('wizard-primary'); + + expect(primary).toBeDisabled(); + await fireEvent.update(inputFor(getByTestId('assistant-model-api-key')), ' model-key '); + await waitFor(() => expect(primary).not.toBeDisabled()); + await fireEvent.click(primary); + + await waitFor(() => + expect(store.verifyModel).toHaveBeenCalledWith({ + connection: { type: 'anthropicApi', data: { apiKey: 'model-key' } }, + modelName: 'claude-opus-5', + }), + ); + expect(store.setField).toHaveBeenCalledWith('modelConnection', { + type: 'anthropicApi', + data: { apiKey: 'model-key' }, + }); + expect(store.setField).toHaveBeenCalledWith('modelName', 'claude-opus-5'); + expect(store.save).toHaveBeenCalledWith(false); + expect(store.refreshInstanceModelCredentials).toHaveBeenCalled(); + expect(await findByText('instanceAi.onboarding.model.success')).toBeVisible(); + await waitFor(() => expect(emitted().advance).toEqual([[]]), { timeout: 2500 }); + }); + + it('keeps the model step open on verification failure and clears the error after editing', async () => { + const { pinia, store } = setupStore(); + vi.mocked(store.verifyModel).mockResolvedValue({ ok: false, failure: 'unauthorized' }); + const { emitted, findByTestId, getByTestId, queryByTestId } = renderWizard({ pinia }); + const apiKey = inputFor(await findByTestId('assistant-model-api-key')); + + await fireEvent.update(apiKey, 'wrong-key'); + await fireEvent.click(getByTestId('wizard-primary')); + await waitFor(() => expect(getByTestId('assistant-verification-error')).toBeVisible()); + expect(store.save).not.toHaveBeenCalled(); + expect(emitted().advance).toBeUndefined(); + + await fireEvent.update(apiKey, 'new-key'); + await waitFor(() => expect(queryByTestId('assistant-verification-error')).toBeNull()); + }); + + it('verifies an environment-managed model without sending a connection', async () => { + const { pinia, store } = setupStore({ + modelName: 'claude-opus-5', + modelEnvConfigured: true, + sandboxEnvConfigured: true, + envManaged: { + model: { provider: true, apiKey: true, baseUrl: false, model: false }, + sandbox: { provider: true, serviceUrl: true, apiKey: true }, + search: { provider: false, apiKey: false, url: false }, + }, + }); + vi.mocked(store.verifyModel).mockResolvedValue({ ok: true, latencyMs: 10 }); + const { emitted, findByText, getByTestId } = renderWizard({ pinia }); + + expect(await findByText('instanceAi.onboarding.env.title')).toBeVisible(); + await fireEvent.click(getByTestId('wizard-primary')); + + await waitFor(() => + expect(store.verifyModel).toHaveBeenCalledWith({ modelName: 'claude-opus-5' }), + ); + expect(store.setField).toHaveBeenCalledWith('modelName', 'claude-opus-5'); + expect(store.setField).toHaveBeenCalledWith('sandboxEnabled', true); + await waitFor(() => expect(emitted().advance).toEqual([[]]), { timeout: 2500 }); + }); + + it('supports a custom OpenAI-compatible model without an API key', async () => { + const { pinia, store } = setupStore(); + vi.mocked(store.verifyModel).mockResolvedValue({ ok: true }); + const { findByTestId, findByText, getByTestId } = renderWizard({ pinia }); + + const providerSelect = await findByTestId('assistant-model-provider'); + await fireEvent.click(inputFor(providerSelect)); + await fireEvent.click(await findByText('instanceAi.onboarding.model.customProvider')); + await fireEvent.update( + inputFor(getByTestId('assistant-model-base-url')), + ' http://ollama:11434/v1 ', + ); + await fireEvent.update(inputFor(getByTestId('assistant-model-name')), ' qwen3-coder '); + await fireEvent.click(getByTestId('wizard-primary')); + + await waitFor(() => + expect(store.verifyModel).toHaveBeenCalledWith({ + connection: { type: 'openAiApi', data: { url: 'http://ollama:11434/v1' } }, + modelName: 'qwen3-coder', + }), + ); + }); + + it('verifies and saves an n8n Sandbox connection', async () => { + const { pinia, store } = setupStore(); + vi.mocked(store.verifySandbox).mockResolvedValue({ ok: true, startupMs: 1500 }); + const { emitted, findByTestId, findByText, getByTestId } = renderWizard({ + pinia, + props: { step: 'sandbox' }, + }); + + await fireEvent.click(await findByTestId('assistant-sandbox-n8n-sandbox')); + await fireEvent.update(inputFor(getByTestId('assistant-sandbox-url')), ' http://sandbox:3200 '); + await fireEvent.update(inputFor(getByTestId('assistant-sandbox-api-key')), ' sandbox-key '); + await fireEvent.click(getByTestId('wizard-primary')); + + const connection = { + type: 'httpHeaderAuth', + data: { name: 'x-api-key', value: 'sandbox-key' }, + }; + await waitFor(() => + expect(store.verifySandbox).toHaveBeenCalledWith({ + provider: 'n8n-sandbox', + connection, + serviceUrl: 'http://sandbox:3200', + }), + ); + expect(store.setField).toHaveBeenCalledWith('sandboxConnection', connection); + expect(store.setField).toHaveBeenCalledWith('sandboxProvider', 'n8n-sandbox'); + expect(store.setField).toHaveBeenCalledWith('sandboxEnabled', true); + expect(store.setField).toHaveBeenCalledWith('n8nSandboxServiceUrl', 'http://sandbox:3200'); + expect(store.refreshCredentials).toHaveBeenCalled(); + expect(await findByText('instanceAi.onboarding.sandbox.success')).toBeVisible(); + await waitFor(() => expect(emitted().advance).toEqual([[]]), { timeout: 2500 }); + }); + + it('hydrates and verifies a saved Daytona connection', async () => { + const { pinia, store, credentialsStore } = setupStore({ + sandboxProvider: 'daytona', + daytonaCredentialId: 'daytona-credential', + }); + vi.mocked(credentialsStore.getCredentialData).mockResolvedValue({ + data: { apiKey: 'saved-daytona-key' }, + } as never); + vi.mocked(store.verifySandbox).mockResolvedValue({ ok: true }); + const { getByTestId } = renderWizard({ pinia, props: { step: 'sandbox' } }); + + await waitFor(() => + expect(inputFor(getByTestId('assistant-daytona-api-key')).value).toBe('saved-daytona-key'), + ); + await fireEvent.update( + inputFor(getByTestId('assistant-daytona-api-key')), + 'updated-daytona-key', + ); + await fireEvent.click(getByTestId('wizard-primary')); + + await waitFor(() => + expect(store.verifySandbox).toHaveBeenCalledWith({ + provider: 'daytona', + connection: { + type: 'daytonaApi', + data: { + apiUrl: 'https://app.daytona.io/api', + apiKey: 'updated-daytona-key', + }, + }, + }), + ); + }); + + it('selects and assigns an existing Daytona credential in settings', async () => { + const { pinia, store } = setupStore(); + store.$patch({ + serviceCredentials: [ + { id: 'existing-daytona', name: 'Existing Daytona', type: 'daytonaApi' }, + { id: 'existing-sandbox', name: 'Existing Sandbox', type: 'httpHeaderAuth' }, + ], + }); + const { emitted, findByTestId, findByText, getByTestId } = renderWizard({ + pinia, + props: { step: 'sandbox', editMode: true, surface: 'settings' }, + }); + + const existingCredential = await findByTestId('n8n-agent-sandbox-existing-credential-select'); + await fireEvent.click(inputFor(existingCredential)); + await fireEvent.click(await findByText('Existing Daytona · Daytona')); + await fireEvent.click(getByTestId('n8n-agent-sandbox-dialog-save')); + + await waitFor(() => + expect(store.setField).toHaveBeenCalledWith('daytonaCredentialId', 'existing-daytona'), + ); + expect(store.setField).toHaveBeenCalledWith('n8nSandboxCredentialId', null); + expect(store.setField).toHaveBeenCalledWith('sandboxProvider', 'daytona'); + expect(store.setField).toHaveBeenCalledWith('sandboxEnabled', true); + expect(store.refreshCredentials).toHaveBeenCalled(); + await waitFor(() => expect(emitted().advance).toEqual([[]]), { timeout: 2500 }); + }); + + it('confirms an environment-managed sandbox and enables it', async () => { + const { pinia, store } = setupStore({ + sandboxProvider: 'n8n-sandbox', + sandboxEnvConfigured: true, + }); + vi.mocked(store.verifySandbox).mockResolvedValue({ ok: true }); + const { emitted, findByText, getByTestId } = renderWizard({ + pinia, + props: { step: 'sandbox' }, + }); + + expect(await findByText('instanceAi.onboarding.env.title')).toBeVisible(); + await fireEvent.click(getByTestId('wizard-primary')); + + await waitFor(() => + expect(store.verifySandbox).toHaveBeenCalledWith({ provider: 'n8n-sandbox' }), + ); + expect(store.setField).toHaveBeenCalledWith('sandboxEnabled', true); + expect(store.save).toHaveBeenCalledWith(false); + await waitFor(() => expect(emitted().advance).toEqual([[]]), { timeout: 2500 }); + }); + + it('records disabled web search without calling verification', async () => { + const { pinia, store } = setupStore(); + const { emitted, findByTestId, findByText, getByTestId } = renderWizard({ + pinia, + props: { step: 'search' }, + }); + + expect( + (await findByText('instanceAi.onboarding.search.free')).closest('.n8n-badge'), + ).not.toBeNull(); + await fireEvent.click(await findByTestId('assistant-search-disabled')); + await fireEvent.click(getByTestId('wizard-primary')); + + await waitFor(() => expect(store.save).toHaveBeenCalledWith(false)); + expect(store.verifySearch).not.toHaveBeenCalled(); + expect(store.setField).toHaveBeenCalledWith('searchDisabled', true); + expect(store.refreshCredentials).toHaveBeenCalled(); + await waitFor(() => expect(emitted().advance).toEqual([[]]), { timeout: 2500 }); + }); + + it('links Brave Search to its API key dashboard', async () => { + const { pinia } = setupStore(); + const { findByTestId, findByText } = renderWizard({ + pinia, + props: { step: 'search' }, + }); + + await fireEvent.click(await findByTestId('assistant-search-brave')); + const link = (await findByText('instanceAi.onboarding.search.braveKeyLink')).closest('a'); + + expect(link).toHaveAttribute('href', 'https://api-dashboard.search.brave.com/app/keys'); + }); + + it('accepts environment-managed search without sending or saving credentials', async () => { + const { pinia, store } = setupStore({ searchEnvConfigured: true }); + const { emitted, findByText, getByTestId } = renderWizard({ + pinia, + props: { step: 'search' }, + }); + + expect(await findByText('instanceAi.onboarding.env.title')).toBeVisible(); + await fireEvent.click(getByTestId('wizard-primary')); + + await waitFor(() => expect(emitted().advance).toEqual([[]]), { timeout: 2500 }); + expect(store.verifySearch).not.toHaveBeenCalled(); + expect(store.save).not.toHaveBeenCalled(); + }); + + it.each([ + ['searxng', 'searXngApi', 'apiUrl', 'http://searxng:8080'], + ['brave', 'braveSearchApi', 'apiKey', 'brave-key'], + ] as const)('verifies and saves a %s search connection', async (provider, type, field, value) => { + const { pinia, store } = setupStore(); + vi.mocked(store.verifySearch).mockResolvedValue({ ok: true, resultCount: 10 }); + const { emitted, findByTestId, findByText, getByTestId } = renderWizard({ + pinia, + props: { step: 'search' }, + }); + + await fireEvent.click(await findByTestId(`assistant-search-${provider}`)); + await fireEvent.update(inputFor(getByTestId('assistant-search-value')), ` ${value} `); + await fireEvent.click(getByTestId('wizard-primary')); + + const connection = { type, data: { [field]: value } }; + await waitFor(() => expect(store.verifySearch).toHaveBeenCalledWith({ connection })); + expect(store.setField).toHaveBeenCalledWith('searchConnection', connection); + expect(store.setField).toHaveBeenCalledWith('searchDisabled', false); + expect(await findByText('instanceAi.onboarding.search.success')).toBeVisible(); + await waitFor(() => expect(emitted().advance).toEqual([[]]), { timeout: 2500 }); + }); + + it('hydrates a saved search credential and handles verification errors', async () => { + const { pinia, store, credentialsStore } = setupStore({ + searchCredentialId: 'search-credential', + }); + store.$patch({ + serviceCredentials: [ + { id: 'search-credential', name: 'Brave Search', type: 'braveSearchApi' }, + ], + }); + vi.mocked(credentialsStore.getCredentialData).mockResolvedValue({ + data: { apiKey: 'saved-search-key' }, + } as never); + vi.mocked(store.verifySearch).mockRejectedValue(new Error('request failed')); + const { emitted, findByText, getByTestId } = renderWizard({ + pinia, + props: { step: 'search' }, + }); + + await waitFor(() => + expect(inputFor(getByTestId('assistant-search-value')).value).toBe('saved-search-key'), + ); + await fireEvent.click(getByTestId('wizard-primary')); + + expect(await findByText('instanceAi.onboarding.verification.provider_error')).toBeVisible(); + expect(store.save).not.toHaveBeenCalled(); + expect(emitted().advance).toBeUndefined(); + }); + + it('selects and assigns an existing Brave Search credential in settings', async () => { + const { pinia, store } = setupStore(); + store.$patch({ + serviceCredentials: [ + { id: 'existing-brave', name: 'Existing Brave', type: 'braveSearchApi' }, + { id: 'existing-searxng', name: 'Existing SearXNG', type: 'searXngApi' }, + ], + }); + const { emitted, findByTestId, findByText, getByTestId } = renderWizard({ + pinia, + props: { step: 'search', editMode: true, surface: 'settings' }, + }); + + const existingCredential = await findByTestId('n8n-agent-search-existing-credential-select'); + await fireEvent.click(inputFor(existingCredential)); + await fireEvent.click(await findByText('Existing Brave · braveSearchApi')); + await fireEvent.click(getByTestId('n8n-agent-search-dialog-save')); + + await waitFor(() => + expect(store.setField).toHaveBeenCalledWith('searchCredentialId', 'existing-brave'), + ); + expect(store.setField).toHaveBeenCalledWith('searchDisabled', false); + expect(store.refreshCredentials).toHaveBeenCalled(); + await waitFor(() => expect(emitted().advance).toEqual([[]])); + }); + + it('does not offer existing credentials during onboarding', async () => { + const { pinia, store } = setupStore(); + store.$patch({ + instanceModelCredentials: [ + { id: 'first-model', name: 'First model', type: 'openAiApi' }, + { id: 'second-model', name: 'Second model', type: 'anthropicApi' }, + ], + }); + const { findByTestId, queryByTestId } = renderWizard({ pinia }); + + expect(await findByTestId('assistant-model-provider')).toBeVisible(); + expect(queryByTestId('assistant-existing-credential')).toBeNull(); + }); + + it('restores the assigned model connection after switching providers', async () => { + const { pinia, store, credentialsStore } = setupStore({ + modelCredentialId: 'assigned-openai', + modelName: 'gpt-5.6-sol', + }); + store.$patch({ + instanceModelCredentials: [ + { id: 'assigned-openai', name: 'Current OpenAI', type: 'openAiApi' }, + ], + }); + vi.mocked(credentialsStore.getCredentialData).mockResolvedValue({ + data: { apiKey: 'saved-openai-key' }, + } as never); + const { findAllByText, findByTestId, findByText, getByTestId } = renderWizard({ pinia }); + + await waitFor(() => + expect(inputFor(getByTestId('assistant-model-api-key')).value).toBe('saved-openai-key'), + ); + const provider = await findByTestId('assistant-model-provider'); + await fireEvent.click(inputFor(provider)); + await fireEvent.click(await findByText('Anthropic')); + expect(inputFor(getByTestId('assistant-model-api-key')).value).toBe(''); + + await fireEvent.click(inputFor(provider)); + const openAiOptions = await findAllByText('OpenAI'); + await fireEvent.click(openAiOptions.at(-1)!); + + await waitFor(() => + expect(inputFor(getByTestId('assistant-model-api-key')).value).toBe('saved-openai-key'), + ); + }); + + it('opens summary rows for editing and completes from the done step', async () => { + const { pinia } = setupStore(); + const { emitted, findByText, getByTestId } = renderWizard({ + pinia, + props: { step: 'done' }, + }); + + await fireEvent.click( + (await findByText('instanceAi.onboarding.model.label')).closest('button')!, + ); + await fireEvent.click( + (await findByText('instanceAi.onboarding.sandbox.label')).closest('button')!, + ); + await fireEvent.click( + (await findByText('instanceAi.onboarding.search.label')).closest('button')!, + ); + await fireEvent.click(getByTestId('wizard-primary')); + + expect(emitted().edit).toEqual([['model'], ['sandbox'], ['search']]); + expect(emitted().completed).toEqual([[]]); + }); + + it('completes from the compact compose fast path', async () => { + const { pinia } = setupStore(); + const { emitted, findByTestId, queryByText } = renderWizard({ + pinia, + props: { step: 'done', composeFastPath: true }, + }); + + expect(queryByText('anthropic/claude-opus-5')).toBeNull(); + await fireEvent.click(await findByTestId('wizard-primary')); + expect(emitted().completed).toEqual([[]]); + }); + + it('emits back from later setup steps', async () => { + const { pinia } = setupStore(); + const { emitted, findByTestId } = renderWizard({ + pinia, + props: { step: 'sandbox' }, + }); + + expect(await findByTestId('wizard-progress')).toBeVisible(); + await fireEvent.click(await findByTestId('wizard-back')); + + expect(emitted().back).toEqual([[]]); + }); + + it('uses cancel and save without progress controls in direct edit mode', async () => { + const { pinia } = setupStore(); + const { emitted, findByTestId, findByText, queryByTestId } = renderWizard({ + pinia, + props: { step: 'search', editMode: true }, + }); + + expect(await findByText('generic.save')).toBeVisible(); + expect(queryByTestId('wizard-back')).toBeNull(); + expect(queryByTestId('wizard-progress')).toBeNull(); + + await fireEvent.click(await findByTestId('wizard-cancel')); + + expect(emitted()['update:open']).toEqual([[false]]); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/onboarding/InstanceAiOnboardingWizard.vue b/packages/frontend/editor-ui/src/features/ai/instanceAi/onboarding/InstanceAiOnboardingWizard.vue new file mode 100644 index 00000000000..cc84af9e4cb --- /dev/null +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/onboarding/InstanceAiOnboardingWizard.vue @@ -0,0 +1,1572 @@ + + + + + + + diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/onboarding/useInstanceAiOnboarding.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/onboarding/useInstanceAiOnboarding.test.ts new file mode 100644 index 00000000000..afd888e83b2 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/onboarding/useInstanceAiOnboarding.test.ts @@ -0,0 +1,135 @@ +import { ref } from 'vue'; + +import { useInstanceAiOnboarding } from './useInstanceAiOnboarding'; + +function createConfiguration() { + return { + modelConfigured: ref(false), + sandboxConfigured: ref(false), + searchDecided: ref(false), + searchEnvConfigured: ref(false), + }; +} + +describe('useInstanceAiOnboarding', () => { + it('resumes at the first unmet setup step', () => { + const configuration = createConfiguration(); + const onboarding = useInstanceAiOnboarding(configuration); + + expect(onboarding.firstUnmetStep()).toBe('model'); + + configuration.modelConfigured.value = true; + expect(onboarding.firstUnmetStep()).toBe('sandbox'); + + configuration.sandboxConfigured.value = true; + expect(onboarding.firstUnmetStep()).toBe('search'); + + configuration.searchDecided.value = true; + expect(onboarding.firstUnmetStep()).toBe('done'); + }); + + it('omits configured services from the setup sequence', () => { + const configuration = createConfiguration(); + configuration.sandboxConfigured.value = true; + configuration.searchEnvConfigured.value = true; + configuration.searchDecided.value = true; + const onboarding = useInstanceAiOnboarding(configuration); + + expect(onboarding.sequence.value).toEqual(['model', 'done']); + + onboarding.start(); + configuration.modelConfigured.value = true; + onboarding.advance(); + expect(onboarding.step.value).toBe('done'); + }); + + it('keeps a disabled sandbox in the sequence until it is enabled', () => { + const configuration = createConfiguration(); + configuration.modelConfigured.value = true; + configuration.searchEnvConfigured.value = true; + configuration.searchDecided.value = true; + const onboarding = useInstanceAiOnboarding(configuration); + + expect(onboarding.sequence.value).toEqual(['model', 'sandbox', 'done']); + expect(onboarding.firstUnmetStep()).toBe('sandbox'); + + configuration.sandboxConfigured.value = true; + expect(onboarding.sequence.value).toEqual(['model', 'done']); + expect(onboarding.firstUnmetStep()).toBe('done'); + }); + + it('returns to an earlier unmet prerequisite after completing a later checklist step', () => { + const configuration = createConfiguration(); + configuration.modelConfigured.value = true; + const onboarding = useInstanceAiOnboarding(configuration); + + onboarding.start('search'); + configuration.searchDecided.value = true; + onboarding.advance(); + + expect(onboarding.step.value).toBe('sandbox'); + }); + + it('returns to the summary after applying a single-step edit', () => { + const configuration = createConfiguration(); + configuration.modelConfigured.value = true; + configuration.sandboxConfigured.value = true; + configuration.searchDecided.value = true; + const onboarding = useInstanceAiOnboarding(configuration); + + onboarding.start('sandbox', true); + onboarding.advance(); + + expect(onboarding.step.value).toBe('done'); + expect(onboarding.editMode.value).toBe(false); + expect(onboarding.open.value).toBe(true); + }); + + it('closes a direct edit when other setup steps are still incomplete', () => { + const configuration = createConfiguration(); + configuration.modelConfigured.value = true; + const onboarding = useInstanceAiOnboarding(configuration); + + onboarding.start('search', true); + configuration.searchDecided.value = true; + onboarding.advance(); + + expect(onboarding.open.value).toBe(false); + expect(onboarding.editMode.value).toBe(false); + expect(onboarding.firstUnmetStep()).toBe('sandbox'); + }); + + it('clears edit mode when the wizard closes', () => { + const onboarding = useInstanceAiOnboarding(createConfiguration()); + + onboarding.start('search', true); + onboarding.close(); + + expect(onboarding.open.value).toBe(false); + expect(onboarding.editMode.value).toBe(false); + }); + + it('moves back through the visible setup sequence', () => { + const onboarding = useInstanceAiOnboarding(createConfiguration()); + + onboarding.start('search'); + onboarding.back(); + expect(onboarding.step.value).toBe('sandbox'); + + onboarding.back(); + expect(onboarding.step.value).toBe('model'); + + onboarding.back(); + expect(onboarding.step.value).toBe('model'); + }); + + it('returns to the summary when backing out of an edit', () => { + const onboarding = useInstanceAiOnboarding(createConfiguration()); + + onboarding.start('model', true); + onboarding.back(); + + expect(onboarding.step.value).toBe('done'); + expect(onboarding.editMode.value).toBe(false); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/onboarding/useInstanceAiOnboarding.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/onboarding/useInstanceAiOnboarding.ts new file mode 100644 index 00000000000..2d3c02f06aa --- /dev/null +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/onboarding/useInstanceAiOnboarding.ts @@ -0,0 +1,69 @@ +import { computed, ref, type Ref } from 'vue'; + +export type InstanceAiOnboardingStep = 'model' | 'sandbox' | 'search' | 'done'; + +export interface InstanceAiOnboardingConfiguration { + modelConfigured: Ref; + sandboxConfigured: Ref; + searchDecided: Ref; + searchEnvConfigured: Ref; +} + +export function useInstanceAiOnboarding(configuration: InstanceAiOnboardingConfiguration) { + const open = ref(false); + const step = ref('model'); + const editMode = ref(false); + + const sequence = computed(() => [ + 'model', + ...(configuration.sandboxConfigured.value ? [] : (['sandbox'] as const)), + ...(configuration.searchEnvConfigured.value ? [] : (['search'] as const)), + 'done', + ]); + + function firstUnmetStep(): InstanceAiOnboardingStep { + if (!configuration.modelConfigured.value) return 'model'; + if (!configuration.sandboxConfigured.value) return 'sandbox'; + if (!configuration.searchDecided.value && !configuration.searchEnvConfigured.value) { + return 'search'; + } + return 'done'; + } + + function start(target = firstUnmetStep(), editing = false): void { + step.value = target; + editMode.value = editing; + open.value = true; + } + + function close(): void { + open.value = false; + editMode.value = false; + } + + function advance(): void { + if (editMode.value) { + editMode.value = false; + const nextStep = firstUnmetStep(); + if (nextStep === 'done') { + step.value = nextStep; + } else { + open.value = false; + } + return; + } + step.value = firstUnmetStep(); + } + + function back(): void { + if (editMode.value) { + step.value = 'done'; + editMode.value = false; + return; + } + const index = sequence.value.indexOf(step.value); + step.value = sequence.value[Math.max(0, index - 1)] ?? 'model'; + } + + return { open, step, editMode, sequence, firstUnmetStep, start, close, advance, back }; +} diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/views/SettingsInstanceAiView.vue b/packages/frontend/editor-ui/src/features/ai/instanceAi/views/SettingsInstanceAiView.vue index 0db846f481e..8207cbda674 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/views/SettingsInstanceAiView.vue +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/views/SettingsInstanceAiView.vue @@ -33,6 +33,7 @@ import { useInstanceAiBrowserUseExperiment } from '@/experiments/instanceAiBrows import { useInstanceAiComputerUseExperiment } from '@/experiments/instanceAiComputerUse'; import { useInstanceAiMcpConnectionsExperiment } from '@/experiments/instanceAiMcpConnections'; import { useInstanceCredentialTest } from '../composables/useInstanceCredentialTest'; +import { useInstanceAiConfiguration } from '../composables/useInstanceAiConfiguration'; import { useInstanceAiSettingsStore } from '../instanceAiSettings.store'; import { SANDBOX_PROVIDER_LABELS, type InstanceAiConnectionKind } from '../constants'; import ConnectionDialog from '../components/settings/ConnectionDialog.vue'; @@ -45,13 +46,21 @@ const settingsStore = useSettingsStore(); const credentialsStore = useCredentialsStore(); const store = useInstanceAiSettingsStore(); const { isTestingCredential, testSavedCredential } = useInstanceCredentialTest(); +const { + modelCredential, + modelConfigured: isModelConfigured, + sandboxCredentialId, + sandboxConfigured: isSandboxConfigured, + searchCredential, + searchState, +} = useInstanceAiConfiguration(); const { isFeatureEnabled: isMcpConnectionsExperimentEnabled } = useInstanceAiMcpConnectionsExperiment(); const { isFeatureEnabled: isBrowserUseEnabled } = useInstanceAiBrowserUseExperiment(); const { isFeatureEnabled: isComputerUseExperimentEnabled } = useInstanceAiComputerUseExperiment(); -const DOCS_URL = 'https://docs.n8n.io/deploy/host-n8n/configure-n8n/set-up-ai-assistant-preview'; +const DOCS_URL = 'https://docs.n8n.io/deploy/host-n8n/configure-n8n/set-up-ai-assistant'; const isAdmin = computed(() => store.canManage); const isEnabled = computed( @@ -62,19 +71,27 @@ const isMcpAccessEnabled = computed(() => store.settings?.mcpAccessEnabled ?? tr const isSelfManaged = computed(() => !store.isProxyEnabled && !store.isCloudManaged); const showCredentialsRows = computed(() => isAdmin.value && isSelfManaged.value); const showSandboxRow = computed(() => isAdmin.value && !store.isCloudManaged); +const isModelConnectionEnvManaged = computed( + () => + isSelfManaged.value && + (store.settings?.envManaged?.model.provider ?? store.settings?.modelEnvConfigured ?? false), +); +const isModelNameEnvManaged = computed( + () => isSelfManaged.value && (store.settings?.envManaged?.model.model ?? false), +); +const isModelReadOnly = computed( + () => isModelConnectionEnvManaged.value && isModelNameEnvManaged.value, +); +const isSandboxEnvManaged = computed( + () => isSelfManaged.value && (store.settings?.sandboxEnvConfigured ?? false), +); +const isSearchEnvManaged = computed( + () => isSelfManaged.value && (store.settings?.searchEnvConfigured ?? false), +); -const modelCredential = computed(() => - store.instanceModelCredentials.find( - (credential) => credential.id === store.settings?.modelCredentialId, - ), -); -const isModelConfigured = computed(() => - Boolean( - store.settings?.modelEnvConfigured || - (store.settings?.modelCredentialId && store.settings.modelName), - ), -); const modelValue = computed(() => { + if (isModelConnectionEnvManaged.value) + return i18n.baseText('instanceAi.onboarding.foundOnServer'); if (store.settings?.modelCredentialId) { const typeLabel = modelCredential.value ? credentialTypeLabel(modelCredential.value.type) : ''; const modelName = store.settings.modelName ?? ''; @@ -83,21 +100,14 @@ const modelValue = computed(() => { return i18n.baseText('settings.n8nAgent.modelCredential.env.value'); }); const modelDescription = computed<{ key: BaseTextKey; warning: boolean } | null>(() => { - if (store.settings?.modelCredentialId && store.settings.modelName) return null; - if (store.settings?.modelEnvConfigured) + if (isModelConnectionEnvManaged.value) return { key: 'settings.n8nAgent.modelCredential.env.description', warning: false }; + if (store.settings?.modelCredentialId && store.settings.modelName) return null; return { key: 'settings.n8nAgent.modelCredential.missing.description', warning: !isOff.value }; }); -const sandboxCredentialId = computed(() => - store.settings?.sandboxProvider === 'daytona' - ? store.settings?.daytonaCredentialId - : store.settings?.n8nSandboxCredentialId, -); -const isSandboxConfigured = computed(() => - Boolean(sandboxCredentialId.value ?? store.settings?.sandboxEnvConfigured), -); const sandboxValue = computed(() => { + if (isSandboxEnvManaged.value) return i18n.baseText('instanceAi.onboarding.foundOnServer'); if (sandboxCredentialId.value) { return store.settings?.sandboxProvider === 'daytona' ? SANDBOX_PROVIDER_LABELS.daytona @@ -106,25 +116,16 @@ const sandboxValue = computed(() => { return i18n.baseText('settings.n8nAgent.sandbox.env.value'); }); const sandboxDescription = computed<{ key: BaseTextKey; warning: boolean }>(() => { + if (isSandboxEnvManaged.value) + return { key: 'settings.n8nAgent.sandbox.env.description', warning: false }; if (sandboxCredentialId.value) return { key: 'settings.n8nAgent.sandbox.set.description', warning: false }; - if (store.settings?.sandboxEnvConfigured) - return { key: 'settings.n8nAgent.sandbox.env.description', warning: false }; return { key: 'settings.n8nAgent.sandbox.missing.description', warning: !isOff.value }; }); -const searchCredential = computed(() => - store.serviceCredentials.find( - (credential) => credential.id === store.settings?.searchCredentialId, - ), -); -const searchState = computed<'set' | 'env' | 'notset'>(() => { - if (store.settings?.searchCredentialId) return 'set'; - if (store.settings?.searchEnvConfigured) return 'env'; - return 'notset'; -}); const searchValue = computed(() => { - if (searchState.value === 'env') return i18n.baseText('settings.n8nAgent.search.env.value'); + if (isSearchEnvManaged.value) return i18n.baseText('instanceAi.onboarding.foundOnServer'); + if (searchState.value === 'disabled') return i18n.baseText('instanceAi.onboarding.disabled'); return searchCredential.value ? credentialTypeLabel(searchCredential.value.type) : ''; }); @@ -132,7 +133,8 @@ const isSetupRequired = computed( () => isEnabled.value && ((showCredentialsRows.value && !isModelConfigured.value) || - (showSandboxRow.value && !isSandboxConfigured.value)), + (showSandboxRow.value && !isSandboxConfigured.value) || + (showCredentialsRows.value && searchState.value === 'notset')), ); const neverConfigured = computed(() => { if (isEnabled.value) return false; @@ -271,51 +273,84 @@ function setDialogOpen(kind: InstanceAiConnectionKind, isOpen: boolean) { } function openModelDialog() { + if (isModelReadOnly.value) return; setupChain.value = false; activeDialog.value = 'model'; } function openModelSetup() { - setupChain.value = !isSandboxConfigured.value; + setupChain.value = + (!isSandboxConfigured.value && !isSandboxEnvManaged.value) || searchState.value === 'notset'; activeDialog.value = 'model'; } function openSandboxDialog() { + if (isSandboxEnvManaged.value) return; setupChain.value = false; activeDialog.value = 'sandbox'; } +function openSearchDialog() { + if (isSearchEnvManaged.value) return; + setupChain.value = false; + activeDialog.value = 'search'; +} + +function openSearchSetup(): void { + activeDialog.value = 'search'; +} + /** Returns whether the chain may continue (false only when enabling failed). */ async function finishSetup(): Promise { setupChain.value = false; - if (!enableAfterSetup.value) return true; + if (!enableAfterSetup.value) { + activeDialog.value = null; + return true; + } enableAfterSetup.value = false; - return await store.persistEnabled(true); + const enabled = await store.persistEnabled(true); + if (enabled) activeDialog.value = null; + return enabled; } async function handleModelSaved() { + if ((setupChain.value || enableAfterSetup.value) && !(await enableEnvironmentSandboxIfNeeded())) + return; if (setupChain.value) { - activeDialog.value = 'sandbox'; + if (isSandboxConfigured.value || isSandboxEnvManaged.value) { + openSearchSetup(); + } else { + activeDialog.value = 'sandbox'; + } return; } await finishSetup(); } async function handleSandboxSaved() { - // The optional search step never gates enablement; enable first, then offer it. const chainSearch = setupChain.value && searchState.value === 'notset'; - if (!(await finishSetup())) return; if (chainSearch) { - setupChain.value = true; - activeDialog.value = 'search'; + openSearchSetup(); + return; } + await finishSetup(); +} + +async function handleSearchSaved() { + await finishSetup(); } function credentialTypeLabel(type: string) { return credentialsStore.getCredentialTypeByName(type)?.displayName ?? type; } +async function enableEnvironmentSandboxIfNeeded(): Promise { + if (!isSandboxEnvManaged.value || isSandboxConfigured.value) return true; + store.setField('sandboxEnabled', true); + return await store.save(); +} + onMounted(() => { documentTitle.set(i18n.baseText('settings.n8nAgent')); void store.fetch(); @@ -350,7 +385,9 @@ async function handleEnable() { return; } - if (showSandboxRow.value && !isSandboxConfigured.value) { + if (!(await enableEnvironmentSandboxIfNeeded())) return; + + if (showSandboxRow.value && !isSandboxConfigured.value && !isSandboxEnvManaged.value) { openSandboxDialog(); return; } @@ -369,6 +406,12 @@ async function handleEnable() { } } + if (showCredentialsRows.value && searchState.value === 'notset') { + setupChain.value = true; + openSearchSetup(); + return; + } + await finishSetup(); } @@ -509,7 +552,7 @@ function openAiUsageSettings() { @@ -535,6 +578,14 @@ function openAiUsageSettings() { data-test-id="n8n-agent-model-add" @click="openModelSetup" /> + + {{ modelValue }} + @@ -542,7 +593,7 @@ function openAiUsageSettings() { @@ -556,7 +607,24 @@ function openAiUsageSettings() { diff --git a/packages/frontend/editor-ui/src/features/collaboration/projects/components/ProjectNavigation.test.ts b/packages/frontend/editor-ui/src/features/collaboration/projects/components/ProjectNavigation.test.ts index 8726b36976d..4da40d18180 100644 --- a/packages/frontend/editor-ui/src/features/collaboration/projects/components/ProjectNavigation.test.ts +++ b/packages/frontend/editor-ui/src/features/collaboration/projects/components/ProjectNavigation.test.ts @@ -7,6 +7,7 @@ import ProjectsNavigation from './ProjectNavigation.vue'; import { useProjectsStore } from '../projects.store'; import { useSettingsStore } from '@n8n/stores/settings.store'; import { useUsersStore } from '@n8n/stores/users.store'; +import { useRBACStore } from '@n8n/stores/rbac.store'; vi.mock('vue-router', async () => { const actual = await vi.importActual('vue-router'); @@ -68,6 +69,10 @@ const teamProjects = Array.from({ length: 3 }, () => createProjectListItem('team describe('ProjectsNavigation', () => { beforeEach(() => { + vi.stubGlobal('localStorage', { + getItem: vi.fn().mockReturnValue(null), + setItem: vi.fn(), + }); createTestingPinia(); projectsStore = mockedStore(useProjectsStore); @@ -75,6 +80,32 @@ describe('ProjectsNavigation', () => { usersStore = mockedStore(useUsersStore); }); + function configureInstanceAi(setupCompleted: boolean) { + settingsStore.isModuleActive = vi.fn().mockReturnValue(true); + settingsStore.moduleSettings = { + 'instance-ai': { + enabled: true, + localGatewayDisabled: false, + browserUseEnabled: true, + proxyEnabled: false, + cloudManaged: false, + setupCompleted, + sandboxEnabled: true, + workflowBuilderAvailable: true, + sandboxUnavailableReason: null, + runDebugEnabled: false, + }, + }; + } + + function configureInstanceAiScopes({ canManage }: { canManage: boolean }) { + vi.mocked(useRBACStore().hasScope).mockImplementation((scope) => { + if (scope === 'instanceAi:manage') return canManage; + if (scope === 'instanceAi:message') return true; + return false; + }); + } + it('should not throw an error', () => { projectsStore.teamProjectsLimit = -1; expect(() => { @@ -103,22 +134,10 @@ describe('ProjectsNavigation', () => { expect(getAllByTestId('project-menu-item')).toHaveLength(teamProjects.length); }); - it('should show Instance AI above Home when enabled', () => { + it('should show Instance AI above Home for a member after setup is complete', () => { projectsStore.teamProjectsLimit = -1; - settingsStore.isModuleActive = vi.fn().mockReturnValue(true); - settingsStore.moduleSettings = { - 'instance-ai': { - enabled: true, - localGatewayDisabled: false, - browserUseEnabled: true, - proxyEnabled: false, - cloudManaged: false, - sandboxEnabled: true, - workflowBuilderAvailable: true, - sandboxUnavailableReason: null, - runDebugEnabled: false, - }, - }; + configureInstanceAiScopes({ canManage: false }); + configureInstanceAi(true); const { getByTestId } = renderComponent({ props: { @@ -133,6 +152,26 @@ describe('ProjectsNavigation', () => { ).toBeTruthy(); }); + it('should hide Instance AI from a member until setup is complete', () => { + projectsStore.teamProjectsLimit = -1; + configureInstanceAiScopes({ canManage: false }); + configureInstanceAi(false); + + const { queryByTestId } = renderComponent({ props: { collapsed: false } }); + + expect(queryByTestId('project-instance-ai-menu-item')).toBeNull(); + }); + + it('should show Instance AI to an admin before setup is complete', () => { + projectsStore.teamProjectsLimit = -1; + configureInstanceAiScopes({ canManage: true }); + configureInstanceAi(false); + + const { getByTestId } = renderComponent({ props: { collapsed: false } }); + + expect(getByTestId('project-instance-ai-menu-item')).toBeVisible(); + }); + it('should not show "Projects" title when the menu is collapsed', async () => { projectsStore.teamProjectsLimit = -1; diff --git a/packages/frontend/editor-ui/src/features/collaboration/projects/components/ProjectNavigation.vue b/packages/frontend/editor-ui/src/features/collaboration/projects/components/ProjectNavigation.vue index 649e8015809..3b835872319 100644 --- a/packages/frontend/editor-ui/src/features/collaboration/projects/components/ProjectNavigation.vue +++ b/packages/frontend/editor-ui/src/features/collaboration/projects/components/ProjectNavigation.vue @@ -15,6 +15,7 @@ import { CHAT_VIEW } from '@/features/ai/chatHub/constants'; import { useFavoritesStore } from '@/app/stores/favorites.store'; import { useFavoriteNavItems } from '../composables/useFavoriteNavItems'; import { INSTANCE_AI_VIEW } from '@/features/ai/instanceAi/constants'; +import { useInstanceAiAvailable } from '@/features/ai/instanceAi/composables/useInstanceAiAvailability'; import { WORKFLOW_REVIEW_REQUESTS_VIEW } from '@/features/workflow-reviews/constants'; import { useWorkflowReviewsFeature } from '@/features/workflow-reviews/composables/useWorkflowReviewsFeature'; @@ -52,11 +53,7 @@ const isChatLinkAvailable = computed( settingsStore.isChatFeatureEnabled && hasPermission(['rbac'], { rbac: { scope: 'chatHub:message' } }), ); -const isInstanceAiNavVisible = computed(() => { - if (!settingsStore.isModuleActive('instance-ai')) return false; - const ms = settingsStore.moduleSettings['instance-ai']; - return ms?.enabled !== false; -}); +const isInstanceAiNavVisible = useInstanceAiAvailable(); const hasMultipleVerifiedUsers = computed( () => usersStore.allUsers.filter((user) => !user.isPendingUser).length > 1, ); diff --git a/packages/frontend/editor-ui/src/features/shared/commandBar/composables/useInstanceAiCommands.ts b/packages/frontend/editor-ui/src/features/shared/commandBar/composables/useInstanceAiCommands.ts index 2944d249df9..402d3ea45d4 100644 --- a/packages/frontend/editor-ui/src/features/shared/commandBar/composables/useInstanceAiCommands.ts +++ b/packages/frontend/editor-ui/src/features/shared/commandBar/composables/useInstanceAiCommands.ts @@ -3,9 +3,9 @@ import { useRouter } from 'vue-router'; import { useI18n } from '@n8n/i18n'; import { N8nIcon } from '@n8n/design-system'; import type { CommandGroup, CommandBarItem } from '../types'; -import { useSettingsStore } from '@n8n/stores/settings.store'; import { useInstanceAiStore } from '@/features/ai/instanceAi/instanceAi.store'; import { INSTANCE_AI_VIEW, INSTANCE_AI_THREAD_VIEW } from '@/features/ai/instanceAi/constants'; +import { useInstanceAiAvailable } from '@/features/ai/instanceAi/composables/useInstanceAiAvailability'; const NAME_KEYWORDS = [ 'assistant', @@ -21,14 +21,8 @@ export function useInstanceAiCommands(options: { lastQuery: Ref }): Comm const i18n = useI18n(); const { lastQuery } = options; const router = useRouter(); - const settingsStore = useSettingsStore(); const instanceAiStore = useInstanceAiStore(); - - const isInstanceAiCommandsVisible = computed( - () => - settingsStore.isModuleActive('instance-ai') && - settingsStore.moduleSettings['instance-ai']?.enabled !== false, - ); + const isInstanceAiCommandsVisible = useInstanceAiAvailable(); const filteredThreads = computed(() => { const trimmed = (lastQuery.value || '').trim().toLowerCase(); diff --git a/packages/testing/playwright/pages/InstanceAiPage.ts b/packages/testing/playwright/pages/InstanceAiPage.ts index a74eb42acae..1c702bfdd5c 100644 --- a/packages/testing/playwright/pages/InstanceAiPage.ts +++ b/packages/testing/playwright/pages/InstanceAiPage.ts @@ -37,6 +37,55 @@ export class InstanceAiPage extends BasePage { await expect(this.getSendButton()).toBeVisible({ timeout: 30_000 }); } + async gotoOnboarding(): Promise { + await this.page.goto('/assistant'); + await expect( + this.container + .getByTestId('assistant-setup-intro') + .or(this.container.getByTestId('assistant-setup-incomplete')), + ).toBeVisible({ timeout: 30_000 }); + } + + getSetupButton(): Locator { + return this.container + .getByTestId('assistant-setup-cta') + .or(this.container.getByTestId('assistant-finish-setup-cta')); + } + + getOnboardingWizard(): Locator { + return this.page.getByRole('dialog', { name: 'Set up AI Assistant' }); + } + + getWizardPrimaryButton(): Locator { + return this.getOnboardingWizard().getByTestId('wizard-primary'); + } + + getSearchProvider(provider: 'searxng' | 'brave' | 'disabled'): Locator { + return this.getOnboardingWizard().getByTestId(`assistant-search-${provider}`); + } + + getSearchValueInput(): Locator { + return this.getOnboardingWizard().getByTestId('assistant-search-value'); + } + + getVerificationError(): Locator { + return this.getOnboardingWizard().getByTestId('assistant-verification-error'); + } + + getOnboardingDoneHeading(): Locator { + return this.getOnboardingWizard().getByRole('heading', { + name: 'AI Assistant is on for everyone on this instance', + }); + } + + async mockSearchVerification( + response: { ok: true; resultCount: number } | { ok: false; failure: string }, + ): Promise { + await this.page.route('**/rest/instance-ai/settings/verify/search', async (route) => { + await route.fulfill({ json: { data: response } }); + }); + } + async enableInstanceAiIfPrompted(): Promise { const dialog = this.page.getByRole('dialog').filter({ hasText: 'Try AI Assistant' }); try { diff --git a/packages/testing/playwright/services/api-helper.ts b/packages/testing/playwright/services/api-helper.ts index d9c658a7b21..f97f534eaff 100644 --- a/packages/testing/playwright/services/api-helper.ts +++ b/packages/testing/playwright/services/api-helper.ts @@ -3,6 +3,7 @@ import type { ClusterInfoResponse, InstanceAiEnsureThreadResponse, InstanceAiPermissions, + InstanceAiAdminSettingsUpdateRequest, InstanceAiThreadInfo, } from '@n8n/api-types'; import { request, type APIRequestContext } from '@playwright/test'; @@ -476,6 +477,15 @@ export class ApiHelpers { } } + async updateInstanceAiSettings(settings: InstanceAiAdminSettingsUpdateRequest): Promise { + const response = await this.request.put('/rest/instance-ai/settings', { data: settings }); + if (!response.ok()) { + throw new TestError( + `PUT /rest/instance-ai/settings failed (${response.status()}): ${await response.text()}`, + ); + } + } + /** * Check if n8n is healthy * @returns True if n8n is healthy, false otherwise diff --git a/packages/testing/playwright/tests/e2e/instance-ai/fixtures.ts b/packages/testing/playwright/tests/e2e/instance-ai/fixtures.ts index fd5105a576c..54f118f9a4e 100644 --- a/packages/testing/playwright/tests/e2e/instance-ai/fixtures.ts +++ b/packages/testing/playwright/tests/e2e/instance-ai/fixtures.ts @@ -653,7 +653,8 @@ export const test = base.extend({ }, instanceAiProxySetup: [ - async ({ n8nContainer, backendUrl }, use, testInfo) => { + async ({ n8nContainer, backendUrl, api }, use, testInfo) => { + await api.updateInstanceAiSettings({ searchDisabled: true }); // Local-build mode (no Docker container) — skip all proxy setup. // LLM calls go straight to Anthropic, no recording or replay. if (!n8nContainer) { diff --git a/packages/testing/playwright/tests/e2e/instance-ai/instance-ai-onboarding.spec.ts b/packages/testing/playwright/tests/e2e/instance-ai/instance-ai-onboarding.spec.ts new file mode 100644 index 00000000000..df34a8f9b29 --- /dev/null +++ b/packages/testing/playwright/tests/e2e/instance-ai/instance-ai-onboarding.spec.ts @@ -0,0 +1,49 @@ +import { test, expect } from '../../../fixtures/base'; + +test.use({ + capability: { + services: ['sandbox'], + env: { + TEST_ISOLATION: 'instance-ai-onboarding', + N8N_ENABLED_MODULES: 'instance-ai', + N8N_INSTANCE_AI_MODEL: 'anthropic/claude-sonnet-4-6', + N8N_INSTANCE_AI_MODEL_API_KEY: 'test-model-key', + N8N_INSTANCE_AI_SANDBOX_ENABLED: 'true', + }, + }, +}); + +test.describe( + 'AI Assistant self-hosted onboarding @db:reset', + { annotation: [{ type: 'owner', description: 'instanceAI' }] }, + () => { + test('should verify search and complete onboarding', async ({ n8n }) => { + await n8n.instanceAi.gotoOnboarding(); + await n8n.instanceAi.mockSearchVerification({ ok: true, resultCount: 10 }); + + await n8n.instanceAi.getSetupButton().click(); + await n8n.instanceAi.getSearchProvider('brave').click(); + await n8n.instanceAi.getSearchValueInput().fill('test-search-key'); + await n8n.instanceAi.getWizardPrimaryButton().click(); + + await expect(n8n.instanceAi.getOnboardingDoneHeading()).toBeVisible(); + await n8n.instanceAi.getWizardPrimaryButton().click(); + await expect(n8n.instanceAi.getChatInput()).toBeVisible(); + }); + + test('should keep the search step open when verification fails', async ({ n8n }) => { + await n8n.instanceAi.gotoOnboarding(); + await n8n.instanceAi.mockSearchVerification({ ok: false, failure: 'unauthorized' }); + + await n8n.instanceAi.getSetupButton().click(); + await n8n.instanceAi.getSearchProvider('brave').click(); + await n8n.instanceAi.getSearchValueInput().fill('invalid-search-key'); + await n8n.instanceAi.getWizardPrimaryButton().click(); + + await expect(n8n.instanceAi.getVerificationError()).toContainText( + 'The provider rejected the credential', + ); + await expect(n8n.instanceAi.getSearchProvider('brave')).toBeVisible(); + }); + }, +);