mirror of
https://github.com/n8n-io/n8n.git
synced 2026-08-28 17:22:01 +08:00
feat: Add self-hosted AI Assistant onboarding (#35579)
Signed-off-by: Oleg Ivaniv <me@olegivaniv.com> Co-authored-by: Tuukka Kantola <tuukka@n8n.io>
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<typeof instanceAiVerificationFailureSchema>;
|
||||
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
+435
-36
@@ -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<User>())).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<User>(), 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<User>())).resolves.toBe(
|
||||
'openai/original',
|
||||
);
|
||||
await expect(
|
||||
service.resolveModelConfigForVerification(mock<User>(), '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<User>())).resolves.toEqual({
|
||||
id: 'openai/gpt-4',
|
||||
url: 'https://model.example.com/v1',
|
||||
apiKey: 'model-key',
|
||||
});
|
||||
|
||||
globalConfig.instanceAi.modelUrl = '';
|
||||
await expect(service.resolveModelConfig(mock<User>())).resolves.toEqual({
|
||||
id: 'openai/gpt-4',
|
||||
url: '',
|
||||
apiKey: 'model-key',
|
||||
});
|
||||
|
||||
globalConfig.instanceAi.modelApiKey = '';
|
||||
await expect(service.resolveModelConfig(mock<User>())).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');
|
||||
|
||||
+391
@@ -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<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',
|
||||
} as unknown as InstanceAiConfig,
|
||||
});
|
||||
const logger = mock<Logger>();
|
||||
const settingsService = mock<InstanceAiSettingsService>();
|
||||
const modelService = mock<InstanceAiModelService>();
|
||||
const outboundHttp = mock<OutboundHttp>();
|
||||
const user = mock<User>();
|
||||
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<unknown> | (() => Promise<unknown>)) =>
|
||||
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<unknown> | (() => Promise<unknown>)) => {
|
||||
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' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -121,7 +121,7 @@ function validateModelCredential({
|
||||
data,
|
||||
}: {
|
||||
type: string;
|
||||
data: ICredentialDataDecryptedObject;
|
||||
data: Record<string, unknown>;
|
||||
}): 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<string, unknown>,
|
||||
): Record<string, string> | undefined {
|
||||
const headers: Record<string, string> = {};
|
||||
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<InstanceAiAdminSettingsResponse> {
|
||||
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<InstanceAiConnectionUpdate> {
|
||||
const prepared = await this.prepareConnection(
|
||||
INSTANCE_AI_MODEL_CREDENTIAL_POLICY,
|
||||
'AI Assistant model',
|
||||
connection,
|
||||
);
|
||||
return this.connectionForVerification(prepared);
|
||||
}
|
||||
|
||||
async resolveSandboxConnectionForVerification(
|
||||
connection: InstanceAiConnectionUpdate,
|
||||
): Promise<InstanceAiConnectionUpdate> {
|
||||
const prepared = await this.prepareSandboxConnection(connection);
|
||||
return this.connectionForVerification(prepared);
|
||||
}
|
||||
|
||||
async resolveSearchConnectionForVerification(
|
||||
connection: InstanceAiConnectionUpdate,
|
||||
): Promise<InstanceAiConnectionUpdate> {
|
||||
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<InstanceAiProviderConnection[]> {
|
||||
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<boolean> {
|
||||
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<ModelConfig> {
|
||||
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<ModelConfig> {
|
||||
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<ModelConfig | null> {
|
||||
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<string, unknown>,
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -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<InstanceAiVerificationResponse> {
|
||||
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<InstanceAiVerificationResponse> {
|
||||
const provider = request.provider ?? this.globalConfig.instanceAi.sandboxProvider;
|
||||
let abortSignal: AbortSignal | undefined;
|
||||
let raceWithAbort: typeof import('@n8n/agents').raceWithAbort | undefined;
|
||||
let workspace:
|
||||
| Awaited<ReturnType<typeof import('@n8n/instance-ai')['createWorkspace']>>
|
||||
| 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<InstanceAiVerificationResponse> {
|
||||
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<string, unknown> = {},
|
||||
): void {
|
||||
this.logger.warn(`Instance AI ${kind} verification failed`, {
|
||||
...context,
|
||||
error: ensureError(error).message,
|
||||
failure,
|
||||
});
|
||||
}
|
||||
|
||||
private async resolveSandboxConfig(
|
||||
_user: User,
|
||||
request: InstanceAiVerifySandboxRequest,
|
||||
): Promise<SandboxConfig> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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' } })
|
||||
);
|
||||
}
|
||||
|
||||
@@ -619,6 +619,7 @@ describe('useGlobalEntityCreation', () => {
|
||||
describe('instance-ai module', () => {
|
||||
const INSTANCE_AI_SETTINGS = {
|
||||
enabled: true,
|
||||
setupCompleted: true,
|
||||
localGatewayDisabled: false,
|
||||
browserUseEnabled: true,
|
||||
proxyEnabled: false,
|
||||
|
||||
@@ -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<Item | null>(() =>
|
||||
isInstanceAiAvailable.value
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, onUnmounted, provide, ref, watch } from 'vue';
|
||||
import { computed, onMounted, onUnmounted, provide, ref, watch } from 'vue';
|
||||
import { onBeforeRouteLeave, RouterView, useRoute, useRouter } from 'vue-router';
|
||||
import { N8nResizeWrapper } from '@n8n/design-system';
|
||||
import { useEventListener, useSessionStorage } from '@vueuse/core';
|
||||
@@ -9,14 +9,17 @@ import { useDocumentTitle } from '@/app/composables/useDocumentTitle';
|
||||
import { useTelemetry } from '@n8n/composables/useTelemetry';
|
||||
import { useRootStore } from '@n8n/stores/useRootStore';
|
||||
import { useUIStore } from '@/app/stores/ui.store';
|
||||
import { useSettingsStore } from '@n8n/stores/settings.store';
|
||||
import { useInstanceAiStore } from './instanceAi.store';
|
||||
import { useInstanceAiSettingsStore } from './instanceAiSettings.store';
|
||||
import InstanceAiThreadList from './components/InstanceAiThreadList.vue';
|
||||
import { INSTANCE_AI_VIEW, isInstanceAiChatRoute } from './constants';
|
||||
import { SidebarStateKey } from './instanceAiLayout';
|
||||
import InstanceAiOnboardingView from './onboarding/InstanceAiOnboardingView.vue';
|
||||
|
||||
const store = useInstanceAiStore();
|
||||
const settingsStore = useInstanceAiSettingsStore();
|
||||
const appSettingsStore = useSettingsStore();
|
||||
const i18n = useI18n();
|
||||
const documentTitle = useDocumentTitle();
|
||||
const route = useRoute();
|
||||
@@ -25,6 +28,38 @@ const uiStore = useUIStore();
|
||||
const rootStore = useRootStore();
|
||||
const telemetry = useTelemetry();
|
||||
const { isCtrlKeyPressed } = useDeviceSupport();
|
||||
const setupCompletionState = computed(
|
||||
() => appSettingsStore.moduleSettings['instance-ai']?.setupCompleted,
|
||||
);
|
||||
const setupWasIncomplete = setupCompletionState.value === false;
|
||||
let setupWasObservedIncomplete = setupWasIncomplete;
|
||||
const onboardingCompletionPending = useSessionStorage(
|
||||
'instanceAi.onboarding.completionPending',
|
||||
setupWasIncomplete,
|
||||
);
|
||||
if (setupCompletionState.value === true) onboardingCompletionPending.value = false;
|
||||
else if (setupWasIncomplete) onboardingCompletionPending.value = true;
|
||||
const onboardingActive = ref(
|
||||
setupCompletionState.value !== true && (setupWasIncomplete || onboardingCompletionPending.value),
|
||||
);
|
||||
const showOnboarding = computed(
|
||||
() =>
|
||||
settingsStore.canManage &&
|
||||
!settingsStore.isProxyEnabled &&
|
||||
!settingsStore.isCloudManaged &&
|
||||
onboardingActive.value,
|
||||
);
|
||||
|
||||
watch(setupCompletionState, (setupCompleted) => {
|
||||
if (setupCompleted === true) {
|
||||
onboardingCompletionPending.value = false;
|
||||
if (!setupWasObservedIncomplete) onboardingActive.value = false;
|
||||
} else if (setupCompleted === false) {
|
||||
setupWasObservedIncomplete = true;
|
||||
onboardingCompletionPending.value = true;
|
||||
onboardingActive.value = true;
|
||||
}
|
||||
});
|
||||
|
||||
documentTitle.set(i18n.baseText('instanceAi.view.title'));
|
||||
|
||||
@@ -47,6 +82,11 @@ function handleSidebarResize({ width }: { width: number }) {
|
||||
sidebarWidth.value = width;
|
||||
}
|
||||
|
||||
function handleOnboardingCompleted() {
|
||||
onboardingCompletionPending.value = false;
|
||||
onboardingActive.value = false;
|
||||
}
|
||||
|
||||
provide(SidebarStateKey, {
|
||||
collapsed: sidebarCollapsed,
|
||||
width: sidebarWidth,
|
||||
@@ -79,6 +119,9 @@ useEventListener(document, 'keydown', (event: KeyboardEvent) => {
|
||||
// These run once when the user enters the InstanceAi feature. Route changes
|
||||
// (empty ↔ thread) don't remount the layout, so the listeners persist.
|
||||
onMounted(() => {
|
||||
if (showOnboarding.value && route.name !== INSTANCE_AI_VIEW) {
|
||||
void router.replace({ name: INSTANCE_AI_VIEW });
|
||||
}
|
||||
// In-app navigations expose the previous route via history state; direct
|
||||
// visits (bookmark, external link) fall back to the document referrer.
|
||||
const previousRoute = router.options.history.state.back;
|
||||
@@ -146,27 +189,30 @@ onUnmounted(() => {
|
||||
|
||||
<template>
|
||||
<div :class="$style.container" data-test-id="instance-ai-container">
|
||||
<!-- Resizable sidebar -->
|
||||
<Transition name="sidebar-slide">
|
||||
<N8nResizeWrapper
|
||||
v-if="!sidebarCollapsed"
|
||||
:class="$style.sidebar"
|
||||
:width="sidebarWidth"
|
||||
:style="{ width: `${sidebarWidth}px` }"
|
||||
:supported-directions="['right']"
|
||||
:is-resizing-enabled="true"
|
||||
:min-width="200"
|
||||
:max-width="400"
|
||||
@resize="handleSidebarResize"
|
||||
>
|
||||
<InstanceAiThreadList @collapse="toggleSidebarCollapse" />
|
||||
</N8nResizeWrapper>
|
||||
</Transition>
|
||||
<InstanceAiOnboardingView v-if="showOnboarding" @completed="handleOnboardingCompleted" />
|
||||
<template v-else>
|
||||
<!-- Resizable sidebar -->
|
||||
<Transition name="sidebar-slide">
|
||||
<N8nResizeWrapper
|
||||
v-if="!sidebarCollapsed"
|
||||
:class="$style.sidebar"
|
||||
:width="sidebarWidth"
|
||||
:style="{ width: `${sidebarWidth}px` }"
|
||||
:supported-directions="['right']"
|
||||
:is-resizing-enabled="true"
|
||||
:min-width="200"
|
||||
:max-width="400"
|
||||
@resize="handleSidebarResize"
|
||||
>
|
||||
<InstanceAiThreadList @collapse="toggleSidebarCollapse" />
|
||||
</N8nResizeWrapper>
|
||||
</Transition>
|
||||
|
||||
<!-- Inner route — Empty for `/assistant`, Thread for `/assistant/:threadId` -->
|
||||
<RouterView v-slot="{ Component }">
|
||||
<component :is="Component" :key="String(route.params.threadId ?? 'empty')" />
|
||||
</RouterView>
|
||||
<!-- Inner route — Empty for `/assistant`, Thread for `/assistant/:threadId` -->
|
||||
<RouterView v-slot="{ Component }">
|
||||
<component :is="Component" :key="String(route.params.threadId ?? 'empty')" />
|
||||
</RouterView>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
+104
-2
@@ -1,20 +1,29 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { fireEvent, waitFor } from '@testing-library/vue';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import InstanceAiView from '../InstanceAiView.vue';
|
||||
import { useInstanceAiSettingsStore } from '../instanceAiSettings.store';
|
||||
import { INSTANCE_AI_VIEW } from '../constants';
|
||||
import { useSettingsStore } from '@n8n/stores/settings.store';
|
||||
import { hasPermission } from '@/app/utils/rbac/permissions';
|
||||
|
||||
const TEST_INSTANCE_ID = 'test-instance-id';
|
||||
|
||||
const routerPush = vi.hoisted(() => vi.fn());
|
||||
const routerReplace = vi.hoisted(() => vi.fn());
|
||||
const routerHistoryState = vi.hoisted(() => ({ back: null as string | null }));
|
||||
const routeState = vi.hoisted(() => ({ name: '', params: { threadId: 'thread-1' } }));
|
||||
const telemetryTrack = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('vue-router', async (importOriginal) => ({
|
||||
...(await importOriginal()),
|
||||
useRoute: () => ({ params: { threadId: 'thread-1' } }),
|
||||
useRouter: () => ({ push: routerPush, options: { history: { state: routerHistoryState } } }),
|
||||
useRoute: () => routeState,
|
||||
useRouter: () => ({
|
||||
push: routerPush,
|
||||
replace: routerReplace,
|
||||
options: { history: { state: routerHistoryState } },
|
||||
}),
|
||||
onBeforeRouteLeave: vi.fn(),
|
||||
RouterView: { template: '<div data-test-id="router-view-stub" />' },
|
||||
}));
|
||||
@@ -29,6 +38,10 @@ vi.mock('@n8n/composables/useDeviceSupport', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/app/utils/rbac/permissions', () => ({
|
||||
hasPermission: vi.fn().mockReturnValue(false),
|
||||
}));
|
||||
|
||||
vi.mock('@n8n/stores/useRootStore', () => ({
|
||||
useRootStore: () => ({ instanceId: TEST_INSTANCE_ID }),
|
||||
}));
|
||||
@@ -37,6 +50,10 @@ const renderView = createComponentRenderer(InstanceAiView, {
|
||||
global: {
|
||||
stubs: {
|
||||
InstanceAiThreadList: { template: '<div data-test-id="thread-list-stub" />' },
|
||||
InstanceAiOnboardingView: {
|
||||
emits: ['completed'],
|
||||
template: '<button data-test-id="onboarding-view-stub" @click="$emit(\'completed\')" />',
|
||||
},
|
||||
N8nResizeWrapper: { template: '<div><slot /></div>' },
|
||||
},
|
||||
},
|
||||
@@ -50,9 +67,13 @@ describe('InstanceAiView', () => {
|
||||
const settingsStore = useInstanceAiSettingsStore();
|
||||
settingsStore.refreshModuleSettings = vi.fn().mockResolvedValue(undefined);
|
||||
settingsStore.ensurePreferencesLoaded = vi.fn().mockResolvedValue(undefined);
|
||||
vi.mocked(hasPermission).mockReturnValue(false);
|
||||
routerPush.mockClear();
|
||||
routerReplace.mockClear();
|
||||
telemetryTrack.mockClear();
|
||||
routerHistoryState.back = null;
|
||||
routeState.name = INSTANCE_AI_VIEW;
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
it('opens a new thread with Ctrl/Cmd+Shift+O', () => {
|
||||
@@ -92,4 +113,85 @@ describe('InstanceAiView', () => {
|
||||
source_url: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('shows onboarding to self-managed admins and returns to chat after completion', async () => {
|
||||
vi.mocked(hasPermission).mockReturnValue(true);
|
||||
useSettingsStore().moduleSettings = {
|
||||
'instance-ai': {
|
||||
enabled: true,
|
||||
localGatewayDisabled: false,
|
||||
browserUseEnabled: true,
|
||||
proxyEnabled: false,
|
||||
cloudManaged: false,
|
||||
setupCompleted: false,
|
||||
sandboxEnabled: false,
|
||||
workflowBuilderAvailable: false,
|
||||
sandboxUnavailableReason: null,
|
||||
runDebugEnabled: false,
|
||||
},
|
||||
};
|
||||
routeState.name = 'InstanceAiThread';
|
||||
|
||||
const { findByTestId, getByTestId, queryByTestId } = renderView({ pinia });
|
||||
|
||||
expect(await findByTestId('onboarding-view-stub')).toBeVisible();
|
||||
expect(routerReplace).toHaveBeenCalledWith({ name: INSTANCE_AI_VIEW });
|
||||
expect(queryByTestId('router-view-stub')).toBeNull();
|
||||
|
||||
await fireEvent.click(getByTestId('onboarding-view-stub'));
|
||||
expect(getByTestId('router-view-stub')).toBeVisible();
|
||||
expect(sessionStorage.getItem('instanceAi.onboarding.completionPending')).toBe('false');
|
||||
});
|
||||
|
||||
it('ignores a stale onboarding session after setup completes in Settings', () => {
|
||||
vi.mocked(hasPermission).mockReturnValue(true);
|
||||
sessionStorage.setItem('instanceAi.onboarding.completionPending', 'true');
|
||||
useSettingsStore().moduleSettings = {
|
||||
'instance-ai': {
|
||||
enabled: true,
|
||||
localGatewayDisabled: false,
|
||||
browserUseEnabled: true,
|
||||
proxyEnabled: false,
|
||||
cloudManaged: false,
|
||||
setupCompleted: true,
|
||||
sandboxEnabled: true,
|
||||
workflowBuilderAvailable: true,
|
||||
sandboxUnavailableReason: null,
|
||||
runDebugEnabled: false,
|
||||
},
|
||||
};
|
||||
|
||||
const { getByTestId, queryByTestId } = renderView({ pinia });
|
||||
|
||||
expect(queryByTestId('onboarding-view-stub')).toBeNull();
|
||||
expect(getByTestId('router-view-stub')).toBeVisible();
|
||||
expect(sessionStorage.getItem('instanceAi.onboarding.completionPending')).toBe('false');
|
||||
});
|
||||
|
||||
it('keeps the active wizard open when its final save completes setup', async () => {
|
||||
vi.mocked(hasPermission).mockReturnValue(true);
|
||||
const appSettingsStore = useSettingsStore();
|
||||
appSettingsStore.moduleSettings = {
|
||||
'instance-ai': {
|
||||
enabled: true,
|
||||
localGatewayDisabled: false,
|
||||
browserUseEnabled: true,
|
||||
proxyEnabled: false,
|
||||
cloudManaged: false,
|
||||
setupCompleted: false,
|
||||
sandboxEnabled: true,
|
||||
workflowBuilderAvailable: true,
|
||||
sandboxUnavailableReason: null,
|
||||
runDebugEnabled: false,
|
||||
},
|
||||
};
|
||||
const { getByTestId, queryByTestId } = renderView({ pinia });
|
||||
|
||||
appSettingsStore.moduleSettings = {
|
||||
'instance-ai': { ...appSettingsStore.moduleSettings['instance-ai']!, setupCompleted: true },
|
||||
};
|
||||
|
||||
await waitFor(() => expect(getByTestId('onboarding-view-stub')).toBeVisible());
|
||||
expect(queryByTestId('router-view-stub')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
+189
-858
File diff suppressed because it is too large
Load Diff
+42
@@ -36,6 +36,9 @@ const mockFetchPreferences = vi.fn();
|
||||
const mockUpdatePreferences = vi.fn();
|
||||
const mockFetchServiceCredentials = vi.fn().mockResolvedValue([]);
|
||||
const mockFetchInstanceModelCredentials = vi.fn().mockResolvedValue([]);
|
||||
const mockVerifyModel = vi.fn();
|
||||
const mockVerifySandbox = vi.fn();
|
||||
const mockVerifySearch = vi.fn();
|
||||
const mockCreateGatewayLink = vi.fn();
|
||||
const mockDisconnectGatewaySession = vi.fn();
|
||||
|
||||
@@ -46,6 +49,9 @@ vi.mock('../instanceAi.settings.api', () => ({
|
||||
updatePreferences: (...args: unknown[]) => mockUpdatePreferences(...args),
|
||||
fetchServiceCredentials: (...args: unknown[]) => mockFetchServiceCredentials(...args),
|
||||
fetchInstanceModelCredentials: (...args: unknown[]) => mockFetchInstanceModelCredentials(...args),
|
||||
verifyModel: (...args: unknown[]) => mockVerifyModel(...args),
|
||||
verifySandbox: (...args: unknown[]) => mockVerifySandbox(...args),
|
||||
verifySearch: (...args: unknown[]) => mockVerifySearch(...args),
|
||||
}));
|
||||
|
||||
const mockGetGatewayStatus = vi.fn();
|
||||
@@ -375,6 +381,42 @@ describe('useInstanceAiSettingsStore', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('onboarding verification', () => {
|
||||
it('delegates model, sandbox, and search checks to the settings API', async () => {
|
||||
mockVerifyModel.mockResolvedValue({ ok: true, latencyMs: 10 });
|
||||
mockVerifySandbox.mockResolvedValue({ ok: true, startupMs: 20 });
|
||||
mockVerifySearch.mockResolvedValue({ ok: true, resultCount: 10 });
|
||||
const modelPayload = { modelName: 'gpt-5.6-sol' };
|
||||
const sandboxPayload = { provider: 'n8n-sandbox' as const };
|
||||
const searchPayload = {
|
||||
connection: { type: 'braveSearchApi', data: { apiKey: 'key' } },
|
||||
};
|
||||
|
||||
await expect(store.verifyModel(modelPayload)).resolves.toEqual({ ok: true, latencyMs: 10 });
|
||||
await expect(store.verifySandbox(sandboxPayload)).resolves.toEqual({
|
||||
ok: true,
|
||||
startupMs: 20,
|
||||
});
|
||||
await expect(store.verifySearch(searchPayload)).resolves.toEqual({
|
||||
ok: true,
|
||||
resultCount: 10,
|
||||
});
|
||||
|
||||
expect(mockVerifyModel).toHaveBeenCalledWith(
|
||||
{ baseUrl: 'http://localhost:5678/rest' },
|
||||
modelPayload,
|
||||
);
|
||||
expect(mockVerifySandbox).toHaveBeenCalledWith(
|
||||
{ baseUrl: 'http://localhost:5678/rest' },
|
||||
sandboxPayload,
|
||||
);
|
||||
expect(mockVerifySearch).toHaveBeenCalledWith(
|
||||
{ baseUrl: 'http://localhost:5678/rest' },
|
||||
searchPayload,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('provider credentials', () => {
|
||||
it('refreshes n8n Sandbox credentials when the assistant proxy is enabled', async () => {
|
||||
setModuleSettings(settingsStore, { proxyEnabled: true, cloudManaged: false });
|
||||
|
||||
+5
@@ -1,7 +1,12 @@
|
||||
import { ref } from 'vue';
|
||||
import { createMemoryHistory, createRouter, type RouteRecordRaw } from 'vue-router';
|
||||
import { InstanceAiModule } from '../module.descriptor';
|
||||
import { INSTANCE_AI_VIEW, INSTANCE_AI_THREAD_VIEW, INSTANCE_AI_SETTINGS_VIEW } from '../constants';
|
||||
|
||||
vi.mock('../composables/useInstanceAiAvailability', () => ({
|
||||
useInstanceAiAvailable: () => ref(true),
|
||||
}));
|
||||
|
||||
const stub = { render: () => null };
|
||||
|
||||
// Swap real lazy components for a stub so navigation doesn't pull the view tree.
|
||||
|
||||
+20
-688
@@ -1,41 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, toRaw, watch } from 'vue';
|
||||
import type { ICredentialDataDecryptedObject } from 'n8n-workflow';
|
||||
import {
|
||||
INSTANCE_AI_MODEL_CREDENTIAL_TYPES,
|
||||
INSTANCE_AI_SEARCH_CREDENTIAL_TYPES,
|
||||
type InstanceAiProviderConnection,
|
||||
} from '@n8n/api-types';
|
||||
import {
|
||||
N8nButton,
|
||||
N8nDialog,
|
||||
N8nDialogDescription,
|
||||
N8nDialogFooter,
|
||||
N8nDialogHeader,
|
||||
N8nDialogTitle,
|
||||
N8nInput,
|
||||
N8nInputLabel,
|
||||
N8nOption,
|
||||
N8nSelect,
|
||||
N8nText,
|
||||
} from '@n8n/design-system';
|
||||
import { type BaseTextKey, useI18n } from '@n8n/i18n';
|
||||
import type { IUpdateInformation } from '@/Interface';
|
||||
import Banner from '@/app/components/Banner.vue';
|
||||
import { useLatestFetch } from '@/app/composables/useLatestFetch';
|
||||
import { provideWorkflowDocumentStore } from '@/app/stores/workflowDocument.store';
|
||||
import { useCredentialsStore } from '@/features/credentials/credentials.store';
|
||||
import { SANDBOX_PROVIDER_LABELS, type InstanceAiConnectionKind } from '../../constants';
|
||||
import { useInstanceAiSetupSteps } from '../../composables/useInstanceAiSetupSteps';
|
||||
import { useInstanceCredentialTest } from '../../composables/useInstanceCredentialTest';
|
||||
import { useInstanceAiSettingsStore } from '../../instanceAiSettings.store';
|
||||
import ConnectionFields from './ConnectionFields.vue';
|
||||
|
||||
const DAYTONA_DEFAULT_API_URL = 'https://app.daytona.io/api';
|
||||
const N8N_SANDBOX_HEADER = 'x-api-key';
|
||||
const DEFAULT_SEARCH_TYPE = 'searXngApi';
|
||||
const SANDBOX_CREDENTIAL_TYPES = ['daytonaApi', 'httpHeaderAuth'];
|
||||
const SETUP_STEP: Record<InstanceAiConnectionKind, number> = { model: 1, sandbox: 2, search: 3 };
|
||||
import type { InstanceAiConnectionKind } from '../../constants';
|
||||
import InstanceAiOnboardingWizard from '../../onboarding/InstanceAiOnboardingWizard.vue';
|
||||
import type { InstanceAiOnboardingStep } from '../../onboarding/useInstanceAiOnboarding';
|
||||
|
||||
const open = defineModel<boolean>('open', { required: true });
|
||||
|
||||
@@ -43,663 +9,29 @@ const props = withDefaults(defineProps<{ kind: InstanceAiConnectionKind; setup?:
|
||||
setup: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{ saved: []; back: [] }>();
|
||||
const emit = defineEmits<{ saved: [] }>();
|
||||
|
||||
const i18n = useI18n();
|
||||
const store = useInstanceAiSettingsStore();
|
||||
const credentialsStore = useCredentialsStore();
|
||||
const readOnly = computed(() => !store.canManageInstanceCredentials);
|
||||
const {
|
||||
credentialTestError,
|
||||
isTestingCredential,
|
||||
testCredential,
|
||||
testSavedCredential,
|
||||
restoreStoredError,
|
||||
} = useInstanceCredentialTest();
|
||||
const { stepLabel, isLastStep } = useInstanceAiSetupSteps(SETUP_STEP[props.kind]);
|
||||
const setupSequence: InstanceAiOnboardingStep[] = ['model', 'sandbox', 'search', 'done'];
|
||||
|
||||
provideWorkflowDocumentStore();
|
||||
|
||||
/** For 'sandbox' the selection is a provider ('daytona' | 'n8n-sandbox'); otherwise a credential type. */
|
||||
const selection = ref('');
|
||||
const selectedCredentialId = ref('');
|
||||
const selectingExistingCredential = ref(false);
|
||||
const fieldsData = ref<ICredentialDataDecryptedObject>({});
|
||||
/** The one extra input a kind may have: the model name (model) or the API key (n8n sandbox). */
|
||||
const extraValue = ref('');
|
||||
const isLoading = ref(false);
|
||||
const { next: nextHydration } = useLatestFetch();
|
||||
|
||||
let hydratedSelection = '';
|
||||
let hydratedData: ICredentialDataDecryptedObject = {};
|
||||
let hydratedExtra = '';
|
||||
let hydratedSnapshot = '';
|
||||
|
||||
const usingExisting = computed(() => readOnly.value || selectingExistingCredential.value);
|
||||
const isProxyDaytonaSelection = computed(
|
||||
() =>
|
||||
props.kind === 'sandbox' &&
|
||||
store.isProxyEnabled &&
|
||||
selection.value === 'daytona' &&
|
||||
!usingExisting.value,
|
||||
);
|
||||
|
||||
function credentialTypeLabel(type: string) {
|
||||
return credentialsStore.getCredentialTypeByName(type)?.displayName ?? type;
|
||||
}
|
||||
|
||||
interface DialogCopy {
|
||||
idPrefix: string;
|
||||
titleKey: BaseTextKey;
|
||||
setupTitleKey: BaseTextKey;
|
||||
descriptionKey: BaseTextKey;
|
||||
setupDescriptionKey?: BaseTextKey;
|
||||
fieldLabelKey: BaseTextKey;
|
||||
placeholderKey?: BaseTextKey;
|
||||
providerHintKey?: BaseTextKey;
|
||||
footnoteKey?: BaseTextKey;
|
||||
testName: string;
|
||||
}
|
||||
|
||||
const DIALOG_COPY: Record<InstanceAiConnectionKind, DialogCopy> = {
|
||||
model: {
|
||||
idPrefix: 'n8n-agent-model',
|
||||
titleKey: 'settings.n8nAgent.modelDialog.title',
|
||||
setupTitleKey: 'settings.n8nAgent.modelDialog.setupTitle',
|
||||
descriptionKey: 'settings.n8nAgent.modelDialog.description',
|
||||
setupDescriptionKey: 'settings.n8nAgent.modelDialog.setupDescription',
|
||||
fieldLabelKey: 'settings.n8nAgent.modelCredential.field',
|
||||
placeholderKey: 'settings.n8nAgent.modelCredential.placeholder',
|
||||
footnoteKey: 'settings.n8nAgent.modelDialog.footnote',
|
||||
testName: 'AI Assistant model',
|
||||
},
|
||||
sandbox: {
|
||||
idPrefix: 'n8n-agent-sandbox',
|
||||
titleKey: 'settings.n8nAgent.sandboxDialog.title',
|
||||
setupTitleKey: 'settings.n8nAgent.sandboxDialog.setupTitle',
|
||||
descriptionKey: 'settings.n8nAgent.sandboxDialog.description',
|
||||
fieldLabelKey: 'settings.n8nAgent.sandboxDialog.provider',
|
||||
providerHintKey: 'settings.n8nAgent.sandboxDialog.providerHint',
|
||||
testName: 'AI Assistant sandbox',
|
||||
},
|
||||
search: {
|
||||
idPrefix: 'n8n-agent-search',
|
||||
titleKey: 'settings.n8nAgent.searchDialog.title',
|
||||
setupTitleKey: 'settings.n8nAgent.searchDialog.setupTitle',
|
||||
descriptionKey: 'settings.n8nAgent.searchDialog.description',
|
||||
fieldLabelKey: 'settings.n8nAgent.searchCredential.label',
|
||||
placeholderKey: 'settings.n8nAgent.searchCredential.placeholder',
|
||||
testName: 'AI Assistant web search',
|
||||
},
|
||||
};
|
||||
|
||||
const copy = DIALOG_COPY[props.kind];
|
||||
|
||||
function environmentConfigured(): boolean {
|
||||
if (props.kind === 'model') return Boolean(store.settings?.modelEnvConfigured);
|
||||
if (props.kind === 'sandbox') return Boolean(store.settings?.sandboxEnvConfigured);
|
||||
return Boolean(store.settings?.searchEnvConfigured);
|
||||
}
|
||||
|
||||
function getAssignedId(): string | null {
|
||||
if (props.kind === 'model') return store.settings?.modelCredentialId ?? null;
|
||||
if (props.kind === 'sandbox') {
|
||||
return store.settings?.sandboxProvider === 'daytona'
|
||||
? (store.settings?.daytonaCredentialId ?? null)
|
||||
: (store.settings?.n8nSandboxCredentialId ?? null);
|
||||
}
|
||||
return store.settings?.searchCredentialId ?? null;
|
||||
}
|
||||
|
||||
function getAssignedSelection(): string {
|
||||
if (props.kind === 'sandbox') {
|
||||
return getAssignedId() ? (store.settings?.sandboxProvider ?? '') : '';
|
||||
}
|
||||
const credentials =
|
||||
props.kind === 'model' ? store.instanceModelCredentials : store.serviceCredentials;
|
||||
return credentials.find(({ id }) => id === getAssignedId())?.type ?? '';
|
||||
}
|
||||
|
||||
function getDefaultSelection(): string {
|
||||
if (props.kind === 'model') return '';
|
||||
if (props.kind === 'sandbox') {
|
||||
return store.settings?.sandboxEnvConfigured ? '' : (store.settings?.sandboxProvider ?? '');
|
||||
}
|
||||
return store.settings?.searchEnvConfigured ? '' : DEFAULT_SEARCH_TYPE;
|
||||
}
|
||||
|
||||
function getProviderOptions(): Array<{ value: string; label: string }> {
|
||||
if (props.kind === 'sandbox') {
|
||||
return [
|
||||
{ value: 'daytona', label: SANDBOX_PROVIDER_LABELS.daytona },
|
||||
{ value: 'n8n-sandbox', label: SANDBOX_PROVIDER_LABELS['n8n-sandbox'] },
|
||||
];
|
||||
}
|
||||
const credentialTypes =
|
||||
props.kind === 'model'
|
||||
? INSTANCE_AI_MODEL_CREDENTIAL_TYPES
|
||||
: INSTANCE_AI_SEARCH_CREDENTIAL_TYPES;
|
||||
return credentialTypes.map((type) => ({ value: type, label: credentialTypeLabel(type) }));
|
||||
}
|
||||
|
||||
function getExistingCredentials(): InstanceAiProviderConnection[] {
|
||||
if (props.kind === 'model') return store.instanceModelCredentials;
|
||||
const allowedTypes =
|
||||
props.kind === 'sandbox' ? SANDBOX_CREDENTIAL_TYPES : INSTANCE_AI_SEARCH_CREDENTIAL_TYPES;
|
||||
return store.serviceCredentials.filter(({ type }) =>
|
||||
allowedTypes.some((allowed) => allowed === type),
|
||||
);
|
||||
}
|
||||
|
||||
function existingCredentialLabel(credential: InstanceAiProviderConnection): string {
|
||||
const detail =
|
||||
props.kind === 'sandbox'
|
||||
? SANDBOX_PROVIDER_LABELS[credential.type === 'daytonaApi' ? 'daytona' : 'n8n-sandbox']
|
||||
: credentialTypeLabel(credential.type);
|
||||
return `${credential.name} · ${detail}`;
|
||||
}
|
||||
|
||||
function selectionForCredential(credential: InstanceAiProviderConnection): string {
|
||||
if (props.kind !== 'sandbox') return credential.type;
|
||||
return credential.type === 'daytonaApi' ? 'daytona' : 'n8n-sandbox';
|
||||
}
|
||||
|
||||
function credentialTypeFor(selected: string): string {
|
||||
if (props.kind !== 'sandbox') return selected;
|
||||
return selected === 'daytona' ? 'daytonaApi' : 'httpHeaderAuth';
|
||||
}
|
||||
|
||||
function seedData(selected: string): ICredentialDataDecryptedObject {
|
||||
return props.kind === 'sandbox' && selected === 'daytona'
|
||||
? { apiUrl: DAYTONA_DEFAULT_API_URL }
|
||||
: {};
|
||||
}
|
||||
|
||||
function applyLoadedData(data: ICredentialDataDecryptedObject): void {
|
||||
if (props.kind === 'sandbox' && selection.value === 'n8n-sandbox') {
|
||||
extraValue.value = typeof data.value === 'string' ? data.value : '';
|
||||
return;
|
||||
}
|
||||
fieldsData.value = data;
|
||||
}
|
||||
|
||||
function newConnectionIsComplete(selected: string): boolean {
|
||||
if (props.kind === 'model') return true;
|
||||
if (props.kind === 'sandbox') {
|
||||
if (selected === 'n8n-sandbox') return extraValue.value.trim().length > 0;
|
||||
if (isProxyDaytonaSelection.value) return true;
|
||||
return (
|
||||
typeof fieldsData.value.apiUrl === 'string' &&
|
||||
fieldsData.value.apiUrl.trim().length > 0 &&
|
||||
typeof fieldsData.value.apiKey === 'string' &&
|
||||
fieldsData.value.apiKey.trim().length > 0
|
||||
);
|
||||
}
|
||||
const field = selected === 'braveSearchApi' ? 'apiKey' : 'apiUrl';
|
||||
const value = fieldsData.value[field];
|
||||
return typeof value === 'string' && value.trim().length > 0;
|
||||
}
|
||||
|
||||
function buildConnectionData(): ICredentialDataDecryptedObject {
|
||||
if (props.kind === 'sandbox' && selection.value === 'n8n-sandbox') {
|
||||
return { name: N8N_SANDBOX_HEADER, value: extraValue.value.trim() };
|
||||
}
|
||||
return { ...toRaw(fieldsData.value) };
|
||||
}
|
||||
|
||||
function stageExisting(): void {
|
||||
if (props.kind === 'model') {
|
||||
store.setField('modelCredentialId', selectedCredentialId.value || null);
|
||||
store.setField('modelName', selectedCredentialId.value ? extraValue.value.trim() : undefined);
|
||||
return;
|
||||
}
|
||||
if (props.kind === 'search') {
|
||||
store.setField('searchCredentialId', selectedCredentialId.value || null);
|
||||
return;
|
||||
}
|
||||
store.setField(
|
||||
'daytonaCredentialId',
|
||||
selection.value === 'daytona' ? selectedCredentialId.value : null,
|
||||
);
|
||||
store.setField(
|
||||
'n8nSandboxCredentialId',
|
||||
selection.value === 'n8n-sandbox' ? selectedCredentialId.value : null,
|
||||
);
|
||||
if (selection.value === 'daytona' || selection.value === 'n8n-sandbox') {
|
||||
store.setField('sandboxProvider', selection.value);
|
||||
}
|
||||
}
|
||||
|
||||
function stageNew(connectionData: ICredentialDataDecryptedObject): void {
|
||||
if (props.kind === 'model') {
|
||||
store.setField('modelConnection', { type: selection.value, data: connectionData });
|
||||
store.setField('modelName', extraValue.value.trim());
|
||||
return;
|
||||
}
|
||||
if (props.kind === 'sandbox') {
|
||||
if (isProxyDaytonaSelection.value) {
|
||||
store.setField('sandboxProvider', 'daytona');
|
||||
return;
|
||||
}
|
||||
store.setField('sandboxConnection', {
|
||||
type: credentialTypeFor(selection.value),
|
||||
data: connectionData,
|
||||
});
|
||||
return;
|
||||
}
|
||||
store.setField('searchConnection', { type: selection.value, data: connectionData });
|
||||
}
|
||||
|
||||
function stageClear(): void {
|
||||
if (props.kind === 'model') {
|
||||
store.setField('modelConnection', null);
|
||||
store.setField('modelName', undefined);
|
||||
} else if (props.kind === 'sandbox') {
|
||||
store.setField('sandboxConnection', null);
|
||||
} else {
|
||||
store.setField('searchConnection', null);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshCredentials(): Promise<void> {
|
||||
isLoading.value = true;
|
||||
try {
|
||||
if (props.kind === 'model') await store.refreshInstanceModelCredentials();
|
||||
else await store.refreshCredentials();
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const assignedId = computed(getAssignedId);
|
||||
const hasSelection = computed(() =>
|
||||
usingExisting.value ? selectedCredentialId.value : selection.value,
|
||||
);
|
||||
const providerOptions = computed(getProviderOptions);
|
||||
const existingOptions = computed(getExistingCredentials);
|
||||
|
||||
const noneLabel = computed(() =>
|
||||
environmentConfigured()
|
||||
? i18n.baseText('settings.n8nAgent.connection.none')
|
||||
: i18n.baseText('settings.n8nAgent.connection.noneNoEnv'),
|
||||
);
|
||||
|
||||
function snapshot() {
|
||||
return JSON.stringify({
|
||||
c: usingExisting.value ? selectedCredentialId.value : '',
|
||||
e: selectingExistingCredential.value,
|
||||
s: selection.value,
|
||||
d: fieldsData.value,
|
||||
x: extraValue.value,
|
||||
});
|
||||
}
|
||||
|
||||
async function hydrate() {
|
||||
const isCurrent = nextHydration();
|
||||
const credentialId = assignedId.value;
|
||||
extraValue.value = props.kind === 'model' ? (store.settings?.modelName ?? '') : '';
|
||||
selectedCredentialId.value = credentialId ?? '';
|
||||
selectingExistingCredential.value = false;
|
||||
selection.value = readOnly.value
|
||||
? getAssignedSelection()
|
||||
: getAssignedSelection() || getDefaultSelection();
|
||||
fieldsData.value = seedData(selection.value);
|
||||
isLoading.value = false;
|
||||
if (credentialId && !readOnly.value) {
|
||||
isLoading.value = true;
|
||||
try {
|
||||
const credential = await credentialsStore.getCredentialData({ id: credentialId });
|
||||
if (!isCurrent()) return;
|
||||
const data = (
|
||||
credential && 'data' in credential ? (credential.data ?? {}) : {}
|
||||
) as ICredentialDataDecryptedObject;
|
||||
applyLoadedData(data);
|
||||
} catch {
|
||||
if (!isCurrent()) return;
|
||||
fieldsData.value = seedData(selection.value);
|
||||
} finally {
|
||||
if (isCurrent()) isLoading.value = false;
|
||||
}
|
||||
}
|
||||
if (!isCurrent()) return;
|
||||
hydratedSelection = selection.value;
|
||||
hydratedData = { ...fieldsData.value };
|
||||
hydratedExtra = extraValue.value;
|
||||
hydratedSnapshot = snapshot();
|
||||
restoreStoredError(credentialId);
|
||||
}
|
||||
|
||||
watch(
|
||||
open,
|
||||
async (isOpen) => {
|
||||
if (isOpen) await hydrate();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
function selectOption(next: string) {
|
||||
const existingCredential = existingOptions.value.find(({ id }) => id === next);
|
||||
if (existingCredential) {
|
||||
credentialTestError.value = '';
|
||||
selectingExistingCredential.value = true;
|
||||
selectedCredentialId.value = existingCredential.id;
|
||||
selection.value = selectionForCredential(existingCredential);
|
||||
fieldsData.value = {};
|
||||
extraValue.value = existingCredential.id === assignedId.value ? hydratedExtra : '';
|
||||
return;
|
||||
}
|
||||
|
||||
const changedMode = selectingExistingCredential.value;
|
||||
if (next === selection.value && !changedMode) return;
|
||||
credentialTestError.value = '';
|
||||
selectingExistingCredential.value = false;
|
||||
selectedCredentialId.value = '';
|
||||
selection.value = next;
|
||||
// Switching starts from a clean slate; only the hydrated selection keeps its values.
|
||||
fieldsData.value = next === hydratedSelection ? { ...hydratedData } : seedData(next);
|
||||
extraValue.value = next === hydratedSelection ? hydratedExtra : '';
|
||||
}
|
||||
|
||||
function selectCredential(nextCredentialId: string) {
|
||||
if (nextCredentialId === selectedCredentialId.value) return;
|
||||
credentialTestError.value = '';
|
||||
selectedCredentialId.value = nextCredentialId;
|
||||
const credential = existingOptions.value.find(({ id }) => id === nextCredentialId);
|
||||
selection.value = credential ? selectionForCredential(credential) : '';
|
||||
extraValue.value = nextCredentialId === assignedId.value ? hydratedExtra : '';
|
||||
}
|
||||
|
||||
function setFieldValue(name: string, value: IUpdateInformation['value']) {
|
||||
fieldsData.value = { ...fieldsData.value, [name]: value } as ICredentialDataDecryptedObject;
|
||||
}
|
||||
|
||||
const isComplete = computed(() => {
|
||||
if (!hasSelection.value) return true;
|
||||
if (props.kind === 'model' && extraValue.value.trim().length === 0) return false;
|
||||
if (usingExisting.value) return true;
|
||||
return newConnectionIsComplete(selection.value);
|
||||
});
|
||||
const isChanged = computed(() => snapshot() !== hydratedSnapshot);
|
||||
const isBusy = computed(() => store.isSaving || isTestingCredential.value || isLoading.value);
|
||||
const primaryDisabled = computed(() => {
|
||||
if (isBusy.value || !isComplete.value) return true;
|
||||
if (props.setup) return !isChanged.value && !hasSelection.value;
|
||||
return !isChanged.value && !credentialTestError.value;
|
||||
});
|
||||
|
||||
async function handlePrimary() {
|
||||
const connectionData = buildConnectionData();
|
||||
if (
|
||||
!usingExisting.value &&
|
||||
selection.value &&
|
||||
!isProxyDaytonaSelection.value &&
|
||||
!(await testCredential({
|
||||
id: selection.value === getAssignedSelection() ? (assignedId.value ?? '') : '',
|
||||
name: copy.testName,
|
||||
type: credentialTypeFor(selection.value),
|
||||
data: connectionData,
|
||||
}))
|
||||
)
|
||||
return;
|
||||
if (!open.value) return;
|
||||
|
||||
// Pre-test a selected existing connection instead of relying on backend validation alone.
|
||||
// Read-only callers cannot fetch credential data, so the backend stays their safety net.
|
||||
if (selectingExistingCredential.value && !readOnly.value && selectedCredentialId.value) {
|
||||
const credential = existingOptions.value.find(({ id }) => id === selectedCredentialId.value);
|
||||
if (credential && !(await testSavedCredential(credential.id, credential.name, credential.type)))
|
||||
return;
|
||||
if (!open.value) return;
|
||||
}
|
||||
|
||||
if (isChanged.value) {
|
||||
if (usingExisting.value) stageExisting();
|
||||
else if (!selection.value) stageClear();
|
||||
else stageNew(connectionData);
|
||||
if (!(await store.save())) return;
|
||||
}
|
||||
await refreshCredentials();
|
||||
if (!open.value) return;
|
||||
// Emit before closing so the host can transition to the next dialog without an all-closed gap.
|
||||
function handleAdvance(): void {
|
||||
emit('saved');
|
||||
open.value = false;
|
||||
if (!props.setup) open.value = false;
|
||||
}
|
||||
|
||||
function handleBack() {
|
||||
if (isBusy.value) return;
|
||||
emit('back');
|
||||
open.value = false;
|
||||
}
|
||||
|
||||
function handleOpenChange(value: boolean) {
|
||||
if (!value && isBusy.value) return;
|
||||
open.value = value;
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
handleOpenChange(false);
|
||||
}
|
||||
|
||||
const title = computed(() => i18n.baseText(props.setup ? copy.setupTitleKey : copy.titleKey));
|
||||
const description = computed(() =>
|
||||
i18n.baseText(
|
||||
props.setup && copy.setupDescriptionKey ? copy.setupDescriptionKey : copy.descriptionKey,
|
||||
),
|
||||
);
|
||||
const showCancel = computed(() => !props.setup || props.kind === 'model');
|
||||
const primaryLabel = computed(() => {
|
||||
if (credentialTestError.value) return i18n.baseText('credentialEdit.credentialConfig.retry');
|
||||
if (props.setup && (props.kind === 'model' || (props.kind === 'sandbox' && !isLastStep.value)))
|
||||
return i18n.baseText('settings.n8nAgent.setup.continue');
|
||||
return i18n.baseText('generic.save');
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<N8nDialog
|
||||
<InstanceAiOnboardingWizard
|
||||
:open="open"
|
||||
size="medium"
|
||||
:show-close-button="!isBusy"
|
||||
:data-test-id="`${copy.idPrefix}-dialog`"
|
||||
@update:open="handleOpenChange"
|
||||
>
|
||||
<N8nDialogHeader>
|
||||
<N8nText
|
||||
v-if="setup"
|
||||
:class="$style.step"
|
||||
size="xsmall"
|
||||
color="text-light"
|
||||
bold
|
||||
tag="p"
|
||||
:data-test-id="`${copy.idPrefix}-dialog-step`"
|
||||
>
|
||||
{{ stepLabel }}
|
||||
</N8nText>
|
||||
<N8nDialogTitle>{{ title }}</N8nDialogTitle>
|
||||
<N8nDialogDescription>{{ description }}</N8nDialogDescription>
|
||||
</N8nDialogHeader>
|
||||
|
||||
<div :class="$style.fields">
|
||||
<N8nInputLabel :label="i18n.baseText(copy.fieldLabelKey)">
|
||||
<N8nSelect
|
||||
v-if="readOnly"
|
||||
:model-value="selectedCredentialId"
|
||||
size="medium"
|
||||
:disabled="isBusy"
|
||||
:placeholder="copy.placeholderKey ? i18n.baseText(copy.placeholderKey) : undefined"
|
||||
:data-test-id="`${copy.idPrefix}-provider-select`"
|
||||
@update:model-value="selectCredential(String($event ?? ''))"
|
||||
>
|
||||
<N8nOption v-if="!setup" value="" :label="noneLabel" />
|
||||
<N8nOption
|
||||
v-for="credential in existingOptions"
|
||||
:key="credential.id"
|
||||
:value="credential.id"
|
||||
:label="existingCredentialLabel(credential)"
|
||||
/>
|
||||
</N8nSelect>
|
||||
<N8nSelect
|
||||
v-else
|
||||
:model-value="selectingExistingCredential ? selectedCredentialId : selection"
|
||||
size="medium"
|
||||
:disabled="isBusy"
|
||||
:placeholder="copy.placeholderKey ? i18n.baseText(copy.placeholderKey) : undefined"
|
||||
:data-test-id="`${copy.idPrefix}-provider-select`"
|
||||
@update:model-value="selectOption(String($event ?? ''))"
|
||||
>
|
||||
<N8nOption v-if="!setup" value="" :label="noneLabel" />
|
||||
<N8nOption
|
||||
v-for="option in providerOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
:label="option.label"
|
||||
/>
|
||||
<N8nOption
|
||||
v-for="credential in existingOptions"
|
||||
:key="credential.id"
|
||||
:value="credential.id"
|
||||
:label="existingCredentialLabel(credential)"
|
||||
/>
|
||||
</N8nSelect>
|
||||
<N8nText
|
||||
v-if="copy.providerHintKey"
|
||||
tag="p"
|
||||
:class="$style.providerHint"
|
||||
size="small"
|
||||
color="text-light"
|
||||
>
|
||||
{{ i18n.baseText(copy.providerHintKey) }}
|
||||
</N8nText>
|
||||
</N8nInputLabel>
|
||||
|
||||
<ConnectionFields
|
||||
v-if="
|
||||
!usingExisting &&
|
||||
selection &&
|
||||
!isProxyDaytonaSelection &&
|
||||
(kind !== 'sandbox' || selection === 'daytona') &&
|
||||
!isLoading
|
||||
"
|
||||
:credential-type="credentialTypeFor(selection)"
|
||||
:data="fieldsData"
|
||||
:disabled="isBusy"
|
||||
:data-test-id="`${copy.idPrefix}-connection-fields`"
|
||||
@update="setFieldValue"
|
||||
/>
|
||||
|
||||
<N8nInputLabel
|
||||
v-if="kind === 'model' && hasSelection"
|
||||
:label="i18n.baseText('settings.n8nAgent.modelName.label')"
|
||||
>
|
||||
<N8nInput
|
||||
:model-value="extraValue"
|
||||
type="text"
|
||||
size="medium"
|
||||
:disabled="isBusy"
|
||||
autocomplete="off"
|
||||
:spellcheck="false"
|
||||
:placeholder="i18n.baseText('settings.n8nAgent.modelName.placeholder')"
|
||||
data-test-id="n8n-agent-model-name-input"
|
||||
@update:model-value="extraValue = String($event)"
|
||||
/>
|
||||
</N8nInputLabel>
|
||||
<N8nInputLabel
|
||||
v-else-if="kind === 'sandbox' && !usingExisting && selection === 'n8n-sandbox'"
|
||||
:label="i18n.baseText('settings.n8nAgent.sandboxCredential.apiKey')"
|
||||
>
|
||||
<N8nInput
|
||||
:model-value="extraValue"
|
||||
type="password"
|
||||
size="medium"
|
||||
:disabled="isBusy"
|
||||
autocomplete="off"
|
||||
:spellcheck="false"
|
||||
data-test-id="n8n-agent-sandbox-api-key-input"
|
||||
@update:model-value="extraValue = String($event)"
|
||||
/>
|
||||
</N8nInputLabel>
|
||||
</div>
|
||||
|
||||
<N8nText
|
||||
v-if="copy.footnoteKey"
|
||||
:class="$style.footnote"
|
||||
size="small"
|
||||
color="text-light"
|
||||
tag="p"
|
||||
>
|
||||
{{ i18n.baseText(copy.footnoteKey) }}
|
||||
</N8nText>
|
||||
<Banner
|
||||
v-if="credentialTestError"
|
||||
theme="danger"
|
||||
:message="i18n.baseText('credentialEdit.credentialConfig.couldntConnectWithTheseSettings')"
|
||||
:details="credentialTestError"
|
||||
:data-test-id="`${copy.idPrefix}-credential-test-error`"
|
||||
/>
|
||||
|
||||
<N8nDialogFooter>
|
||||
<N8nButton
|
||||
v-if="setup && kind !== 'model'"
|
||||
variant="outline"
|
||||
size="medium"
|
||||
:label="i18n.baseText('generic.back')"
|
||||
:disabled="isBusy"
|
||||
:data-test-id="`${copy.idPrefix}-dialog-back`"
|
||||
@click="handleBack"
|
||||
/>
|
||||
<N8nButton
|
||||
v-if="setup && kind === 'search'"
|
||||
variant="outline"
|
||||
size="medium"
|
||||
:label="i18n.baseText('settings.n8nAgent.setup.skip')"
|
||||
:disabled="isBusy"
|
||||
:data-test-id="`${copy.idPrefix}-dialog-skip`"
|
||||
@click="handleClose"
|
||||
/>
|
||||
<N8nButton
|
||||
v-if="showCancel"
|
||||
variant="outline"
|
||||
size="medium"
|
||||
:label="i18n.baseText('generic.cancel')"
|
||||
:disabled="isBusy"
|
||||
:data-test-id="`${copy.idPrefix}-dialog-cancel`"
|
||||
@click="handleClose"
|
||||
/>
|
||||
<N8nButton
|
||||
variant="solid"
|
||||
size="medium"
|
||||
:label="primaryLabel"
|
||||
:loading="isTestingCredential"
|
||||
:disabled="primaryDisabled"
|
||||
:data-test-id="`${copy.idPrefix}-dialog-save`"
|
||||
@click="handlePrimary"
|
||||
/>
|
||||
</N8nDialogFooter>
|
||||
</N8nDialog>
|
||||
:step="kind"
|
||||
:edit-mode="true"
|
||||
:allow-unchanged="setup"
|
||||
:sequence="setupSequence"
|
||||
model-value=""
|
||||
sandbox-value=""
|
||||
search-value=""
|
||||
:compose-fast-path="false"
|
||||
surface="settings"
|
||||
@update:open="open = $event"
|
||||
@advance="handleAdvance"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--sm);
|
||||
margin: var(--spacing--sm) 0;
|
||||
// Long credential forms scroll inside the dialog instead of growing past the viewport.
|
||||
max-height: calc(100dvh - 20rem);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.providerHint {
|
||||
margin: var(--spacing--4xs) 0 0;
|
||||
}
|
||||
|
||||
.footnote {
|
||||
margin: 0 0 var(--spacing--sm);
|
||||
}
|
||||
|
||||
.step {
|
||||
margin: 0;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: var(--letter-spacing--wide);
|
||||
}
|
||||
</style>
|
||||
|
||||
-54
@@ -1,54 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { DOMAIN_RESTRICTION_FIELDS, type ICredentialDataDecryptedObject } from 'n8n-workflow';
|
||||
import type { IUpdateInformation } from '@/Interface';
|
||||
import CredentialInputs from '@/features/credentials/components/CredentialEdit/CredentialInputs.vue';
|
||||
import { useCredentialsStore } from '@/features/credentials/credentials.store';
|
||||
|
||||
const HIDDEN_FIELDS = new Set([
|
||||
...DOMAIN_RESTRICTION_FIELDS.map((field) => field.name),
|
||||
'organizationId',
|
||||
'header',
|
||||
'headerName',
|
||||
'headerValue',
|
||||
]);
|
||||
|
||||
const props = defineProps<{
|
||||
credentialType: string;
|
||||
data: ICredentialDataDecryptedObject;
|
||||
disabled?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{ update: [name: string, value: IUpdateInformation['value']] }>();
|
||||
|
||||
const credentialsStore = useCredentialsStore();
|
||||
|
||||
const type = computed(() => credentialsStore.getCredentialTypeByName(props.credentialType));
|
||||
|
||||
const properties = computed(() =>
|
||||
(type.value?.properties ?? []).filter((property) => !HIDDEN_FIELDS.has(property.name)),
|
||||
);
|
||||
|
||||
function onUpdate(parameterData: IUpdateInformation) {
|
||||
emit('update', parameterData.name, parameterData.value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<fieldset v-if="properties.length" :disabled="disabled" :class="$style.fieldset">
|
||||
<CredentialInputs
|
||||
:credential-properties="properties"
|
||||
:credential-data="data"
|
||||
:documentation-url="type?.documentationUrl ?? ''"
|
||||
@update="onUpdate"
|
||||
/>
|
||||
</fieldset>
|
||||
</template>
|
||||
|
||||
<style module>
|
||||
.fieldset {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
</style>
|
||||
+101
-293
@@ -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<typeof useInstanceAiSettingsStore>;
|
||||
let credentialsStore: ReturnType<typeof useCredentialsStore>;
|
||||
|
||||
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<void>();
|
||||
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<Awaited<ReturnType<typeof credentialsStore.getCredentialData>>>();
|
||||
const fresh =
|
||||
Promise.withResolvers<Awaited<ReturnType<typeof credentialsStore.getCredentialData>>>();
|
||||
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<INodeCredentialTestResult>((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();
|
||||
});
|
||||
});
|
||||
|
||||
+6
-5
@@ -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<boolean> {
|
||||
const settingsStore = useSettingsStore();
|
||||
@@ -16,6 +15,8 @@ export function useInstanceAiAvailable(): ComputedRef<boolean> {
|
||||
() =>
|
||||
settingsStore.isModuleActive('instance-ai') &&
|
||||
settingsStore.moduleSettings['instance-ai']?.enabled !== false &&
|
||||
(settingsStore.moduleSettings['instance-ai']?.setupCompleted === true ||
|
||||
canManageInstanceAi()) &&
|
||||
canMessageInstanceAi(),
|
||||
);
|
||||
}
|
||||
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
import type { InstanceAiAdminSettingsResponse } from '@n8n/api-types';
|
||||
|
||||
import { deriveInstanceAiConfiguration } from './useInstanceAiConfiguration';
|
||||
|
||||
function createSettings(
|
||||
overrides: Partial<InstanceAiAdminSettingsResponse> = {},
|
||||
): 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);
|
||||
});
|
||||
});
|
||||
+29
@@ -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),
|
||||
};
|
||||
}
|
||||
-21
@@ -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 };
|
||||
}
|
||||
@@ -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<InstanceAiProviderConnection[]> {
|
||||
return await makeRestApiRequest(context, 'GET', '/instance-ai/settings/model-credentials');
|
||||
}
|
||||
|
||||
export async function verifyModel(
|
||||
context: IRestApiContext,
|
||||
body: InstanceAiVerifyModelRequest,
|
||||
): Promise<InstanceAiVerificationResponse> {
|
||||
return await makeRestApiRequest(context, 'POST', '/instance-ai/settings/verify/model', body);
|
||||
}
|
||||
|
||||
export async function verifySandbox(
|
||||
context: IRestApiContext,
|
||||
body: InstanceAiVerifySandboxRequest,
|
||||
): Promise<InstanceAiVerificationResponse> {
|
||||
return await makeRestApiRequest(context, 'POST', '/instance-ai/settings/verify/sandbox', body);
|
||||
}
|
||||
|
||||
export async function verifySearch(
|
||||
context: IRestApiContext,
|
||||
body: InstanceAiVerifySearchRequest,
|
||||
): Promise<InstanceAiVerificationResponse> {
|
||||
return await makeRestApiRequest(context, 'POST', '/instance-ai/settings/verify/search', body);
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
+75
@@ -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<InstanceAiSearchProvider, 'disabled'>;
|
||||
credentialType: (typeof INSTANCE_AI_SEARCH_CREDENTIAL_TYPES)[number];
|
||||
label: string;
|
||||
}>;
|
||||
+49
-10
@@ -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<FrontendModuleSettings['instance-ai']> = {
|
||||
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<boolean> {
|
||||
async function save(showToast = true): Promise<boolean> {
|
||||
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<boolean> {
|
||||
async function persistEnabled(value: boolean, showToast = true): Promise<boolean> {
|
||||
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<InstanceAiVerificationResponse> {
|
||||
return await verifyModelRequest(rootStore.restApiContext, payload);
|
||||
}
|
||||
|
||||
async function verifySandbox(
|
||||
payload: InstanceAiVerifySandboxRequest,
|
||||
): Promise<InstanceAiVerificationResponse> {
|
||||
return await verifySandboxRequest(rootStore.restApiContext, payload);
|
||||
}
|
||||
|
||||
async function verifySearch(
|
||||
payload: InstanceAiVerifySearchRequest,
|
||||
): Promise<InstanceAiVerificationResponse> {
|
||||
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,
|
||||
|
||||
@@ -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'],
|
||||
|
||||
+76
@@ -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([[]]);
|
||||
});
|
||||
});
|
||||
+229
@@ -0,0 +1,229 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
N8nButton,
|
||||
N8nHeading,
|
||||
N8nIcon,
|
||||
N8nPreviewTag,
|
||||
N8nSettingsRow,
|
||||
N8nSettingsRowConfigure,
|
||||
N8nSettingsRowGroup,
|
||||
N8nText,
|
||||
} from '@n8n/design-system';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
|
||||
defineProps<{
|
||||
incomplete: boolean;
|
||||
connectModelOnly: boolean;
|
||||
modelValue: string;
|
||||
sandboxValue: string;
|
||||
searchValue: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
setup: [];
|
||||
openStep: [step: 'model' | 'sandbox' | 'search'];
|
||||
turnOff: [];
|
||||
}>();
|
||||
|
||||
const i18n = useI18n();
|
||||
const DOCS_URL = 'https://docs.n8n.io/build/ways-of-building-workflows/ai-assistant';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="$style.page"
|
||||
:data-test-id="incomplete ? 'assistant-setup-incomplete' : 'assistant-setup-intro'"
|
||||
>
|
||||
<div :class="[$style.content, incomplete && $style.wide]">
|
||||
<N8nIcon icon="sparkles" :size="32" :class="$style.heroIcon" />
|
||||
<N8nHeading tag="h1" size="2xlarge" bold :class="$style.title">
|
||||
{{ i18n.baseText('instanceAi.onboarding.title') }}
|
||||
</N8nHeading>
|
||||
<N8nPreviewTag v-if="!incomplete" :class="$style.preview" size="medium" />
|
||||
|
||||
<N8nText v-if="incomplete" tag="p" color="text-base" size="large" :class="$style.lede">
|
||||
{{ i18n.baseText('instanceAi.onboarding.incomplete.lede') }}
|
||||
</N8nText>
|
||||
|
||||
<div v-if="!incomplete" :class="$style.benefits">
|
||||
<div :class="$style.benefit">
|
||||
<N8nIcon icon="workflow" size="small" />
|
||||
<N8nText size="large">{{ i18n.baseText('instanceAi.onboarding.benefit.build') }}</N8nText>
|
||||
</div>
|
||||
<div :class="$style.benefit">
|
||||
<N8nIcon icon="flask-conical" size="small" />
|
||||
<N8nText size="large">{{ i18n.baseText('instanceAi.onboarding.benefit.debug') }}</N8nText>
|
||||
</div>
|
||||
<div :class="$style.benefit">
|
||||
<N8nIcon icon="circle-help" size="small" />
|
||||
<N8nText size="large">{{ i18n.baseText('instanceAi.onboarding.benefit.help') }}</N8nText>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<N8nSettingsRowGroup v-else :class="$style.checklist">
|
||||
<N8nSettingsRow
|
||||
v-for="item in [
|
||||
{
|
||||
id: 'model' as const,
|
||||
title: i18n.baseText('instanceAi.onboarding.model.label'),
|
||||
description: i18n.baseText('instanceAi.onboarding.model.description'),
|
||||
value: modelValue,
|
||||
},
|
||||
{
|
||||
id: 'sandbox' as const,
|
||||
title: i18n.baseText('instanceAi.onboarding.sandbox.label'),
|
||||
description: i18n.baseText('instanceAi.onboarding.sandbox.description'),
|
||||
value: sandboxValue,
|
||||
},
|
||||
{
|
||||
id: 'search' as const,
|
||||
title: i18n.baseText('instanceAi.onboarding.search.label'),
|
||||
description: i18n.baseText('instanceAi.onboarding.search.description'),
|
||||
value: searchValue,
|
||||
},
|
||||
]"
|
||||
:key="item.id"
|
||||
:title="item.title"
|
||||
:description="item.description"
|
||||
clickable
|
||||
:data-test-id="`assistant-setup-checklist-${item.id}`"
|
||||
@click="emit('openStep', item.id)"
|
||||
>
|
||||
<template #action>
|
||||
<N8nSettingsRowConfigure :value="item.value" />
|
||||
</template>
|
||||
</N8nSettingsRow>
|
||||
</N8nSettingsRowGroup>
|
||||
|
||||
<div :class="$style.actions">
|
||||
<N8nButton
|
||||
variant="solid"
|
||||
size="medium"
|
||||
:data-test-id="incomplete ? 'assistant-finish-setup-cta' : 'assistant-setup-cta'"
|
||||
:label="
|
||||
incomplete
|
||||
? i18n.baseText('instanceAi.onboarding.finishSetup')
|
||||
: connectModelOnly
|
||||
? i18n.baseText('instanceAi.onboarding.connectModel')
|
||||
: i18n.baseText('instanceAi.onboarding.setUp')
|
||||
"
|
||||
@click="emit('setup')"
|
||||
/>
|
||||
<N8nButton
|
||||
variant="ghost"
|
||||
size="medium"
|
||||
:href="DOCS_URL"
|
||||
target="_blank"
|
||||
:label="i18n.baseText('instanceAi.onboarding.learnMore')"
|
||||
data-test-id="assistant-learn-more"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div :class="$style.turnOff">
|
||||
<N8nButton
|
||||
variant="ghost"
|
||||
size="small"
|
||||
:label="i18n.baseText('instanceAi.onboarding.turnOff.action')"
|
||||
:class="$style.turnOffButton"
|
||||
data-test-id="assistant-turn-off"
|
||||
@click="emit('turnOff')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
@use '@n8n/design-system/css/mixins/motion.scss' as motion;
|
||||
|
||||
.page {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: var(--spacing--lg) var(--spacing--lg) var(--spacing--3xl);
|
||||
}
|
||||
|
||||
.content {
|
||||
@include motion.fade-in-up;
|
||||
|
||||
width: 100%;
|
||||
max-width: 27.5rem;
|
||||
margin: auto 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.wide {
|
||||
max-width: 35rem;
|
||||
}
|
||||
|
||||
.heroIcon {
|
||||
color: var(--icon-color);
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: var(--spacing--sm) 0 0;
|
||||
}
|
||||
|
||||
.preview {
|
||||
margin-top: var(--spacing--xs);
|
||||
}
|
||||
|
||||
.lede {
|
||||
margin: var(--spacing--sm) 0 0;
|
||||
}
|
||||
|
||||
.benefits {
|
||||
margin-top: var(--spacing--md);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing--2xs);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.benefit {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing--xs);
|
||||
}
|
||||
|
||||
.benefit svg {
|
||||
flex-shrink: 0;
|
||||
color: var(--icon-color);
|
||||
}
|
||||
|
||||
.checklist {
|
||||
width: 100%;
|
||||
margin-top: var(--spacing--lg);
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.actions {
|
||||
margin-top: var(--spacing--lg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--spacing--xs);
|
||||
}
|
||||
|
||||
.turnOff {
|
||||
width: auto;
|
||||
align-self: stretch;
|
||||
margin-inline: var(--spacing--lg);
|
||||
margin-top: var(--spacing--lg);
|
||||
padding-top: var(--spacing--sm);
|
||||
border-top: var(--border);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.turnOffButton {
|
||||
color: var(--text-color--subtler);
|
||||
}
|
||||
</style>
|
||||
+296
@@ -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: `
|
||||
<div
|
||||
data-test-id="intro-stub"
|
||||
:data-incomplete="String(incomplete)"
|
||||
:data-connect-model-only="String(connectModelOnly)"
|
||||
:data-model-value="modelValue"
|
||||
:data-sandbox-value="sandboxValue"
|
||||
:data-search-value="searchValue"
|
||||
>
|
||||
<button data-test-id="intro-setup" @click="$emit('setup')" />
|
||||
<button data-test-id="intro-open-model" @click="$emit('openStep', 'model')" />
|
||||
<button data-test-id="intro-open-search" @click="$emit('openStep', 'search')" />
|
||||
<button data-test-id="intro-turn-off" @click="$emit('turnOff')" />
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
|
||||
const WizardStub = defineComponent({
|
||||
name: 'InstanceAiOnboardingWizard',
|
||||
props: {
|
||||
open: Boolean,
|
||||
step: String,
|
||||
editMode: Boolean,
|
||||
composeFastPath: Boolean,
|
||||
},
|
||||
emits: ['update:open', 'advance', 'back', 'edit', 'completed'],
|
||||
template: `
|
||||
<div
|
||||
data-test-id="wizard-stub"
|
||||
:data-open="String(open)"
|
||||
:data-step="step"
|
||||
:data-edit-mode="String(editMode)"
|
||||
:data-compose-fast-path="String(composeFastPath)"
|
||||
>
|
||||
<button data-test-id="wizard-open" @click="$emit('update:open', true)" />
|
||||
<button data-test-id="wizard-close" @click="$emit('update:open', false)" />
|
||||
<button data-test-id="wizard-advance" @click="$emit('advance')" />
|
||||
<button data-test-id="wizard-back" @click="$emit('back')" />
|
||||
<button data-test-id="wizard-edit-sandbox" @click="$emit('edit', 'sandbox')" />
|
||||
<button data-test-id="wizard-complete" @click="$emit('completed')" />
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
|
||||
const renderView = createComponentRenderer(InstanceAiOnboardingView, {
|
||||
global: {
|
||||
stubs: {
|
||||
InstanceAiOnboardingIntro: IntroStub,
|
||||
InstanceAiOnboardingWizard: WizardStub,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
function setupStore(overrides: Record<string, unknown> = {}) {
|
||||
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 });
|
||||
});
|
||||
});
|
||||
+166
@@ -0,0 +1,166 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { useToast } from '@n8n/composables/useToast';
|
||||
import { MODAL_CONFIRM, VIEWS } from '@/app/constants';
|
||||
import { useMessage } from '@/app/composables/useMessage';
|
||||
import { useCredentialsStore } from '@/features/credentials/credentials.store';
|
||||
import { useInstanceAiConfiguration } from '../composables/useInstanceAiConfiguration';
|
||||
import {
|
||||
INSTANCE_AI_MODEL_PROVIDERS,
|
||||
INSTANCE_AI_SEARCH_PROVIDERS,
|
||||
} from '../instanceAiConnection.constants';
|
||||
import { useInstanceAiSettingsStore } from '../instanceAiSettings.store';
|
||||
import InstanceAiOnboardingIntro from './InstanceAiOnboardingIntro.vue';
|
||||
import InstanceAiOnboardingWizard from './InstanceAiOnboardingWizard.vue';
|
||||
import { useInstanceAiOnboarding, type InstanceAiOnboardingStep } from './useInstanceAiOnboarding';
|
||||
|
||||
const emit = defineEmits<{ completed: [] }>();
|
||||
|
||||
const i18n = useI18n();
|
||||
const router = useRouter();
|
||||
const toast = useToast();
|
||||
const message = useMessage();
|
||||
const store = useInstanceAiSettingsStore();
|
||||
const credentialsStore = useCredentialsStore();
|
||||
const configuration = useInstanceAiConfiguration();
|
||||
|
||||
const sandboxEnvConfigured = computed(() => store.settings?.sandboxEnvConfigured === true);
|
||||
const searchEnvConfigured = computed(() => store.settings?.searchEnvConfigured === true);
|
||||
const searchDecided = computed(() => configuration.searchState.value !== 'notset');
|
||||
const onboarding = useInstanceAiOnboarding({
|
||||
modelConfigured: configuration.modelConfigured,
|
||||
sandboxConfigured: configuration.sandboxConfigured,
|
||||
searchDecided,
|
||||
searchEnvConfigured,
|
||||
});
|
||||
|
||||
const notSet = computed(() => i18n.baseText('instanceAi.onboarding.notSet'));
|
||||
const modelValue = computed(() => {
|
||||
if (!configuration.modelConfigured.value) return notSet.value;
|
||||
if (store.settings?.modelEnvConfigured) {
|
||||
return i18n.baseText('instanceAi.onboarding.foundOnServer');
|
||||
}
|
||||
const provider = INSTANCE_AI_MODEL_PROVIDERS.find(
|
||||
({ credentialType, id }) =>
|
||||
id !== 'custom' && credentialType === configuration.modelCredential.value?.type,
|
||||
)?.id;
|
||||
return provider && store.settings?.modelName
|
||||
? `${provider}/${store.settings.modelName}`
|
||||
: (store.settings?.modelName ?? notSet.value);
|
||||
});
|
||||
const sandboxValue = computed(() => {
|
||||
if (sandboxEnvConfigured.value) {
|
||||
return i18n.baseText('instanceAi.onboarding.foundOnServer');
|
||||
}
|
||||
if (!configuration.sandboxConfigured.value) return notSet.value;
|
||||
return store.settings?.sandboxProvider === 'daytona' ? 'Daytona' : 'n8n Sandbox';
|
||||
});
|
||||
const searchValue = computed(() => {
|
||||
if (configuration.searchState.value === 'notset') return notSet.value;
|
||||
if (configuration.searchState.value === 'disabled') {
|
||||
return i18n.baseText('instanceAi.onboarding.disabled');
|
||||
}
|
||||
if (configuration.searchState.value === 'env') {
|
||||
return i18n.baseText('instanceAi.onboarding.foundOnServer');
|
||||
}
|
||||
const credentialType = configuration.searchCredential.value?.type;
|
||||
return (
|
||||
INSTANCE_AI_SEARCH_PROVIDERS.find(({ credentialType: type }) => type === credentialType)
|
||||
?.label ?? i18n.baseText('instanceAi.onboarding.search.label')
|
||||
);
|
||||
});
|
||||
const composeFastPath = computed(
|
||||
() => configuration.sandboxConfigured.value && searchEnvConfigured.value,
|
||||
);
|
||||
const incomplete = computed(() => configuration.hasSetupProgress.value && !composeFastPath.value);
|
||||
function startAt(step?: Exclude<InstanceAiOnboardingStep, 'done'>): void {
|
||||
onboarding.start(step ?? onboarding.firstUnmetStep());
|
||||
}
|
||||
|
||||
function editStep(step: Exclude<InstanceAiOnboardingStep, 'done'>): void {
|
||||
onboarding.start(step, true);
|
||||
}
|
||||
|
||||
function handleWizardOpenChange(open: boolean): void {
|
||||
if (open) return;
|
||||
if (configuration.setupCompleted.value) {
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
onboarding.close();
|
||||
}
|
||||
|
||||
function finish(): void {
|
||||
if (!configuration.setupCompleted.value) {
|
||||
onboarding.close();
|
||||
return;
|
||||
}
|
||||
onboarding.close();
|
||||
emit('completed');
|
||||
}
|
||||
|
||||
async function turnOff(): Promise<void> {
|
||||
const confirmed = await message.confirm(
|
||||
i18n.baseText('instanceAi.onboarding.turnOff.description'),
|
||||
{
|
||||
title: i18n.baseText('instanceAi.onboarding.turnOff.title'),
|
||||
confirmButtonText: i18n.baseText('instanceAi.onboarding.turnOff.confirm'),
|
||||
cancelButtonText: i18n.baseText('generic.cancel'),
|
||||
},
|
||||
);
|
||||
if (confirmed !== MODAL_CONFIRM || !(await store.persistEnabled(false, false))) return;
|
||||
toast.showMessage({
|
||||
title: i18n.baseText('instanceAi.onboarding.turnOff.toastTitle'),
|
||||
message: i18n.baseText('instanceAi.onboarding.turnOff.toastDescription'),
|
||||
type: 'success',
|
||||
});
|
||||
await router.push({ name: VIEWS.HOMEPAGE });
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([store.fetch(), credentialsStore.fetchCredentialTypes(false)]);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="$style.container">
|
||||
<InstanceAiOnboardingIntro
|
||||
v-if="!store.isLoading"
|
||||
:incomplete="incomplete"
|
||||
:connect-model-only="composeFastPath"
|
||||
:model-value="modelValue"
|
||||
:sandbox-value="sandboxValue"
|
||||
:search-value="searchValue"
|
||||
@setup="startAt()"
|
||||
@open-step="editStep"
|
||||
@turn-off="turnOff"
|
||||
/>
|
||||
|
||||
<InstanceAiOnboardingWizard
|
||||
:open="onboarding.open.value"
|
||||
:step="onboarding.step.value"
|
||||
:edit-mode="onboarding.editMode.value"
|
||||
:sequence="onboarding.sequence.value"
|
||||
:model-value="modelValue"
|
||||
:sandbox-value="sandboxValue"
|
||||
:search-value="searchValue"
|
||||
:compose-fast-path="composeFastPath"
|
||||
@update:open="handleWizardOpenChange"
|
||||
@advance="onboarding.advance"
|
||||
@back="onboarding.back"
|
||||
@edit="editStep"
|
||||
@completed="finish"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.container {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
</style>
|
||||
+527
@@ -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<string, unknown> = {}) {
|
||||
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]]);
|
||||
});
|
||||
});
|
||||
+1572
File diff suppressed because it is too large
Load Diff
+135
@@ -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);
|
||||
});
|
||||
});
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
import { computed, ref, type Ref } from 'vue';
|
||||
|
||||
export type InstanceAiOnboardingStep = 'model' | 'sandbox' | 'search' | 'done';
|
||||
|
||||
export interface InstanceAiOnboardingConfiguration {
|
||||
modelConfigured: Ref<boolean>;
|
||||
sandboxConfigured: Ref<boolean>;
|
||||
searchDecided: Ref<boolean>;
|
||||
searchEnvConfigured: Ref<boolean>;
|
||||
}
|
||||
|
||||
export function useInstanceAiOnboarding(configuration: InstanceAiOnboardingConfiguration) {
|
||||
const open = ref(false);
|
||||
const step = ref<InstanceAiOnboardingStep>('model');
|
||||
const editMode = ref(false);
|
||||
|
||||
const sequence = computed<InstanceAiOnboardingStep[]>(() => [
|
||||
'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 };
|
||||
}
|
||||
+131
-56
@@ -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<boolean> {
|
||||
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<boolean> {
|
||||
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() {
|
||||
<N8nSettingsRow
|
||||
v-if="showCredentialsRows"
|
||||
:class="{ [$style.dim]: isOff }"
|
||||
:clickable="!isOff && isModelConfigured"
|
||||
:clickable="!isOff && isModelConfigured && !isModelReadOnly"
|
||||
data-test-id="n8n-agent-model-row"
|
||||
@click="openModelDialog"
|
||||
>
|
||||
@@ -535,6 +578,14 @@ function openAiUsageSettings() {
|
||||
data-test-id="n8n-agent-model-add"
|
||||
@click="openModelSetup"
|
||||
/>
|
||||
<N8nText
|
||||
v-else-if="isModelReadOnly"
|
||||
size="small"
|
||||
color="text-light"
|
||||
data-test-id="n8n-agent-model-env-value"
|
||||
>
|
||||
{{ modelValue }}
|
||||
</N8nText>
|
||||
<N8nSettingsRowConfigure v-else :value="modelValue" />
|
||||
</template>
|
||||
</N8nSettingsRow>
|
||||
@@ -542,7 +593,7 @@ function openAiUsageSettings() {
|
||||
<N8nSettingsRow
|
||||
v-if="showSandboxRow"
|
||||
:class="{ [$style.dim]: isOff }"
|
||||
:clickable="!isOff && isSandboxConfigured"
|
||||
:clickable="!isOff && isSandboxConfigured && !isSandboxEnvManaged"
|
||||
data-test-id="n8n-agent-sandbox-row"
|
||||
@click="openSandboxDialog"
|
||||
>
|
||||
@@ -556,7 +607,24 @@ function openAiUsageSettings() {
|
||||
</template>
|
||||
<template v-if="!isOff" #action>
|
||||
<N8nButton
|
||||
v-if="!isSandboxConfigured"
|
||||
v-if="isSandboxEnvManaged && !isSandboxConfigured"
|
||||
variant="solid"
|
||||
size="medium"
|
||||
:label="i18n.baseText('settings.n8nAgent.sandbox.enable')"
|
||||
:disabled="store.isSaving"
|
||||
data-test-id="n8n-agent-sandbox-enable"
|
||||
@click="enableEnvironmentSandboxIfNeeded"
|
||||
/>
|
||||
<N8nText
|
||||
v-else-if="isSandboxEnvManaged"
|
||||
size="small"
|
||||
color="text-light"
|
||||
data-test-id="n8n-agent-sandbox-env-value"
|
||||
>
|
||||
{{ sandboxValue }}
|
||||
</N8nText>
|
||||
<N8nButton
|
||||
v-else-if="!isSandboxConfigured"
|
||||
variant="solid"
|
||||
size="medium"
|
||||
:label="i18n.baseText('settings.n8nAgent.sandbox.add')"
|
||||
@@ -579,9 +647,9 @@ function openAiUsageSettings() {
|
||||
<N8nSettingsRow
|
||||
v-if="showCredentialsRows"
|
||||
:class="{ [$style.dim]: isOff }"
|
||||
:clickable="!isOff && searchState !== 'notset'"
|
||||
:clickable="!isOff && searchState !== 'notset' && !isSearchEnvManaged"
|
||||
data-test-id="n8n-agent-search-row"
|
||||
@click="activeDialog = 'search'"
|
||||
@click="openSearchDialog"
|
||||
>
|
||||
<template #info>
|
||||
<span :class="$style.titleWithTag">
|
||||
@@ -608,8 +676,16 @@ function openAiUsageSettings() {
|
||||
:label="i18n.baseText('settings.n8nAgent.search.setup')"
|
||||
:disabled="store.isSaving"
|
||||
data-test-id="n8n-agent-search-setup"
|
||||
@click="activeDialog = 'search'"
|
||||
@click="openSearchDialog"
|
||||
/>
|
||||
<N8nText
|
||||
v-else-if="isSearchEnvManaged"
|
||||
size="small"
|
||||
color="text-light"
|
||||
data-test-id="n8n-agent-search-env-value"
|
||||
>
|
||||
{{ searchValue }}
|
||||
</N8nText>
|
||||
<N8nSettingsRowConfigure v-else :value="searchValue" />
|
||||
</template>
|
||||
</N8nSettingsRow>
|
||||
@@ -745,7 +821,7 @@ function openAiUsageSettings() {
|
||||
</template>
|
||||
|
||||
<ConnectionDialog
|
||||
v-if="showCredentialsRows"
|
||||
v-if="showCredentialsRows && !isModelReadOnly"
|
||||
kind="model"
|
||||
:open="activeDialog === 'model'"
|
||||
:setup="setupChain"
|
||||
@@ -753,21 +829,20 @@ function openAiUsageSettings() {
|
||||
@saved="handleModelSaved"
|
||||
/>
|
||||
<ConnectionDialog
|
||||
v-if="showSandboxRow"
|
||||
v-if="showSandboxRow && !isSandboxEnvManaged"
|
||||
kind="sandbox"
|
||||
:open="activeDialog === 'sandbox'"
|
||||
:setup="showCredentialsRows && setupChain"
|
||||
@update:open="setDialogOpen('sandbox', $event)"
|
||||
@saved="handleSandboxSaved"
|
||||
@back="activeDialog = 'model'"
|
||||
/>
|
||||
<ConnectionDialog
|
||||
v-if="showCredentialsRows"
|
||||
v-if="showCredentialsRows && !isSearchEnvManaged"
|
||||
kind="search"
|
||||
:open="activeDialog === 'search'"
|
||||
:setup="setupChain"
|
||||
@update:open="setDialogOpen('search', $event)"
|
||||
@back="activeDialog = 'sandbox'"
|
||||
@saved="handleSearchSaved"
|
||||
/>
|
||||
</N8nSettingsLayout>
|
||||
</template>
|
||||
|
||||
+54
-15
@@ -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;
|
||||
|
||||
|
||||
+2
-5
@@ -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,
|
||||
);
|
||||
|
||||
+2
-8
@@ -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<string> }): 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();
|
||||
|
||||
@@ -37,6 +37,55 @@ export class InstanceAiPage extends BasePage {
|
||||
await expect(this.getSendButton()).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
async gotoOnboarding(): Promise<void> {
|
||||
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<void> {
|
||||
await this.page.route('**/rest/instance-ai/settings/verify/search', async (route) => {
|
||||
await route.fulfill({ json: { data: response } });
|
||||
});
|
||||
}
|
||||
|
||||
async enableInstanceAiIfPrompted(): Promise<void> {
|
||||
const dialog = this.page.getByRole('dialog').filter({ hasText: 'Try AI Assistant' });
|
||||
try {
|
||||
|
||||
@@ -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<void> {
|
||||
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
|
||||
|
||||
@@ -653,7 +653,8 @@ export const test = base.extend<InstanceAiFixtures>({
|
||||
},
|
||||
|
||||
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) {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
},
|
||||
);
|
||||
Reference in New Issue
Block a user