feat: Configure the AI Assistant through a redesigned instance settings page (#34493)

This commit is contained in:
Albert Alises
2026-07-27 12:07:28 +02:00
committed by GitHub
parent eb0f9ca4e7
commit 4078318cff
34 changed files with 6113 additions and 1424 deletions
+5 -1
View File
@@ -336,7 +336,10 @@ export {
gatewayConfirmationRequiredPayloadSchema,
instanceGatewayResourceDecisionSchema,
instanceAiSandboxProviderSchema,
instanceAiConnectionSchema,
isInstanceAiSandboxProvider,
INSTANCE_AI_MODEL_CREDENTIAL_TYPES,
INSTANCE_AI_SEARCH_CREDENTIAL_TYPES,
GATEWAY_CONFIRMATION_REQUIRED_PREFIX,
InstanceAiSendMessageRequest,
InstanceAiEvalExecutionRequest,
@@ -437,8 +440,9 @@ export type {
InstanceAiConfirmResponse,
InstanceAiAdminSettingsResponse,
InstanceAiUserPreferencesResponse,
InstanceAiModelCredential,
InstanceAiProviderConnection,
InstanceAiSandboxProvider,
InstanceAiConnectionUpdate,
InstanceAiMcpConnectionResponse,
InstanceAiMcpConnectionToolFilterResponse,
InstanceAiMcpConnectionToolResponse,
@@ -1411,23 +1411,48 @@ export function isInstanceAiSandboxProvider(value: unknown): value is InstanceAi
return instanceAiSandboxProviderSchema.safeParse(value).success;
}
export const INSTANCE_AI_MODEL_CREDENTIAL_TYPES = [
'openAiApi',
'anthropicApi',
'googlePalmApi',
'groqApi',
'deepSeekApi',
'mistralCloudApi',
'xAiApi',
'openRouterApi',
'cohereApi',
] as const;
export const INSTANCE_AI_SEARCH_CREDENTIAL_TYPES = ['braveSearchApi', 'searXngApi'] as const;
export interface InstanceAiAdminSettingsResponse {
enabled: boolean;
permissions: InstanceAiPermissions;
mcpServers: string;
mcpAccessEnabled: boolean;
sandboxEnabled: boolean;
sandboxProvider: InstanceAiSandboxProvider;
sandboxImage: string;
sandboxTimeout: number;
daytonaCredentialId: string | null;
n8nSandboxCredentialId: string | null;
searchCredentialId: string | null;
modelCredentialId: string | null;
modelName: string | null;
modelEnvConfigured: boolean;
sandboxEnvConfigured: boolean;
searchEnvConfigured: boolean;
localGatewayDisabled: boolean;
browserUseEnabled: boolean;
}
/**
* Inline provider-connection payload: the credential type plus its field
* values. `null` clears the connection (and falls back to env config).
*/
export const instanceAiConnectionSchema = z.object({
type: z.string().min(1),
data: z.record(z.string(), z.unknown()),
});
export type InstanceAiConnectionUpdate = z.infer<typeof instanceAiConnectionSchema>;
export class InstanceAiAdminSettingsUpdateRequest extends Z.class({
enabled: z.boolean().optional(),
permissions: instanceAiPermissionsSchema.partial().optional(),
@@ -1441,6 +1466,10 @@ export class InstanceAiAdminSettingsUpdateRequest extends Z.class({
n8nSandboxCredentialId: z.string().nullable().optional(),
searchCredentialId: z.string().nullable().optional(),
modelCredentialId: z.string().nullable().optional(),
modelConnection: instanceAiConnectionSchema.nullable().optional(),
sandboxConnection: instanceAiConnectionSchema.nullable().optional(),
searchConnection: instanceAiConnectionSchema.nullable().optional(),
modelName: z.string().trim().min(1).nullable().optional(),
localGatewayDisabled: z.boolean().optional(),
browserUseEnabled: z.boolean().optional(),
}) {}
@@ -1463,11 +1492,10 @@ export class InstanceAiUserPreferencesUpdateRequest extends Z.class({
localGatewayDisabled: z.boolean().optional(),
}) {}
export interface InstanceAiModelCredential {
export interface InstanceAiProviderConnection {
id: string;
name: string;
type: string;
provider: string;
}
// ---------------------------------------------------------------------------
@@ -2,7 +2,10 @@
## Environment Variables
All Instance AI configuration is done via environment variables.
Environment variables define Instance AI defaults and fallbacks. On direct self-hosted
deployments, admins can override the model credential and model name in Instance AI settings.
The sandbox provider is also overridable there (via the sandbox connection); a provider
persisted in settings takes precedence over `N8N_INSTANCE_AI_SANDBOX_PROVIDER`.
### Core
@@ -63,7 +66,7 @@ When no search provider is available, the `web-search` action is disabled. `fetc
| Variable | Type | Default | Description |
|----------|------|---------|-------------|
| `N8N_INSTANCE_AI_SANDBOX_ENABLED` | boolean | `false` | Enable sandbox-backed workflow building. When false, workflow builder capability is unavailable. |
| `N8N_INSTANCE_AI_SANDBOX_PROVIDER` | string | `n8n-sandbox` | Sandbox provider: `n8n-sandbox` for the n8n sandbox service, or `daytona` for the Daytona provider. |
| `N8N_INSTANCE_AI_SANDBOX_PROVIDER` | string | `n8n-sandbox` | Sandbox provider: `n8n-sandbox` for the n8n sandbox service, or `daytona` for the Daytona provider. On self-hosted, a provider selected in Instance AI settings takes precedence. |
| `DAYTONA_API_URL` | string | `''` | Daytona API URL (e.g. `https://app.daytona.io/api`). Required when provider is `daytona`. |
| `DAYTONA_API_KEY` | string | `''` | Daytona API key for authentication. Required when provider is `daytona`. |
| `N8N_SANDBOX_SERVICE_URL` | string | `''` | n8n sandbox service URL. Required when provider is `n8n-sandbox`. |
@@ -123,6 +126,17 @@ avoid mangling normal output. The `PiiDetectionType` API also reserves `phone`
and `address`, but those have no detection pattern yet — setting them has no
effect (they were deferred as too false-positive-prone for free-form prose).
## Provider connections
On self-hosted deployments, owners and admins can configure the model, sandbox, and
web-search connections from the AI Assistant settings page. These connections are
managed centrally and are not offered as workflow-canvas credentials.
The environment variables above remain the fallback when no provider connection is
selected. The effective model name resolves as the instance setting, then the per-user
preference, then `N8N_INSTANCE_AI_MODEL`. Cloud and proxy-managed deployments receive
these values from the managed service instead.
## Enabling / Disabling
The module is **enabled** when `N8N_INSTANCE_AI_MODEL` is set to a non-empty value.
@@ -103,7 +103,7 @@ describe('InstanceAiSandboxService', () => {
const config = await service.resolveSandboxConfig(fakeUser);
expect(resolveDaytonaConfig).toHaveBeenCalledWith(fakeUser);
expect(resolveDaytonaConfig).toHaveBeenCalledWith();
expect(config).toMatchObject({
enabled: true,
provider: 'daytona',
@@ -112,7 +112,7 @@ describe('InstanceAiSandboxService', () => {
});
});
it('routes Daytona traffic through the assistant proxy when enabled', async () => {
it('forces Daytona traffic through the assistant proxy when enabled', async () => {
const getInstanceAiApiProxyToken = vi.fn(async () => ({ accessToken: 'token-1' }));
const client = {
getSandboxProxyConfig: vi.fn(async () => ({ image: 'proxy-image' })),
@@ -120,7 +120,7 @@ describe('InstanceAiSandboxService', () => {
getInstanceAiApiProxyToken,
};
const { service } = createSandboxService({
config: { sandboxEnabled: true, sandboxProvider: 'daytona' },
config: { sandboxEnabled: true, sandboxProvider: 'n8n-sandbox' },
aiService: {
isProxyEnabled: vi.fn(() => true),
getClient: vi.fn(async () => client),
@@ -479,6 +479,176 @@ describe('InstanceAiSandboxService', () => {
expect(setupSandboxWorkspace).toHaveBeenCalledTimes(2);
});
it('rebuilds a cached workspace when direct credentials change', async () => {
let apiKey = 'old-key';
const resolveDaytonaConfig = vi.fn(async () => ({
apiUrl: 'https://daytona.example.com',
apiKey,
}));
const { service } = createSandboxService({
config: { sandboxEnabled: true, sandboxProvider: 'daytona' },
settingsService: { resolveDaytonaConfig },
});
const firstWorkspace = { init: vi.fn(async () => {}), destroy: vi.fn(async () => {}) };
const secondWorkspace = { init: vi.fn(async () => {}), destroy: vi.fn(async () => {}) };
(createSandbox as Mock)
.mockResolvedValueOnce({ id: 'sandbox-1' })
.mockResolvedValueOnce({ id: 'sandbox-2' });
(createWorkspace as Mock)
.mockReturnValueOnce(firstWorkspace)
.mockReturnValueOnce(secondWorkspace);
(setupSandboxWorkspace as Mock).mockResolvedValue(undefined);
const first = await service.getOrCreateWorkspace(
'thread-1',
fakeUser,
{} as InstanceAiContext,
);
apiKey = 'new-key';
const second = await service.getOrCreateWorkspace(
'thread-1',
fakeUser,
{} as InstanceAiContext,
);
expect(second).not.toBe(first);
expect(createSandbox).toHaveBeenCalledTimes(2);
});
it('reuses proxy workspaces without refetching proxy config', async () => {
const client = {
getSandboxProxyConfig: vi.fn(async () => ({ image: 'proxy-image' })),
getSandboxProxyBaseUrl: vi.fn(() => 'https://proxy.base'),
getInstanceAiApiProxyToken: vi.fn(async () => ({ accessToken: 'token-1' })),
};
const { service } = createSandboxService({
config: { sandboxEnabled: true, sandboxProvider: 'n8n-sandbox' },
aiService: {
isProxyEnabled: vi.fn(() => true),
getClient: vi.fn(async () => client),
},
});
const workspace = { init: vi.fn(async () => {}), destroy: vi.fn(async () => {}) };
(createSandbox as Mock).mockResolvedValue({ id: 'sandbox-1' });
(createWorkspace as Mock).mockReturnValue(workspace);
(setupSandboxWorkspace as Mock).mockResolvedValue(undefined);
const first = await service.getOrCreateWorkspace(
'thread-1',
fakeUser,
{} as InstanceAiContext,
);
const second = await service.getOrCreateWorkspace(
'thread-1',
fakeUser,
{} as InstanceAiContext,
);
expect(second).toBe(first);
expect(client.getSandboxProxyConfig).toHaveBeenCalledTimes(1);
});
it('rebuilds a cached workspace after settings change', async () => {
const { service } = createSandboxService({
config: { sandboxEnabled: true, sandboxProvider: 'daytona' },
});
const firstWorkspace = { init: vi.fn(async () => {}), destroy: vi.fn(async () => {}) };
const secondWorkspace = { init: vi.fn(async () => {}), destroy: vi.fn(async () => {}) };
(createSandbox as Mock)
.mockResolvedValueOnce({ id: 'sandbox-1' })
.mockResolvedValueOnce({ id: 'sandbox-2' });
(createWorkspace as Mock)
.mockReturnValueOnce(firstWorkspace)
.mockReturnValueOnce(secondWorkspace);
(setupSandboxWorkspace as Mock).mockResolvedValue(undefined);
const first = await service.getOrCreateWorkspace(
'thread-1',
fakeUser,
{} as InstanceAiContext,
);
service.invalidateCachedWorkspaces();
const second = await service.getOrCreateWorkspace(
'thread-1',
fakeUser,
{} as InstanceAiContext,
);
expect(second).not.toBe(first);
expect(createSandbox).toHaveBeenCalledTimes(2);
expect(firstWorkspace.destroy).not.toHaveBeenCalled();
});
it('re-resolves config when settings change during config resolution', async () => {
let resolveFirstConfig!: (config: { apiKey: string }) => void;
const firstConfig = new Promise<{ apiKey: string }>((resolve) => {
resolveFirstConfig = resolve;
});
const resolveDaytonaConfig = vi
.fn()
.mockReturnValueOnce(firstConfig)
.mockResolvedValueOnce({ apiKey: 'new-key' });
const { service } = createSandboxService({
config: { sandboxEnabled: true, sandboxProvider: 'daytona' },
settingsService: { resolveDaytonaConfig },
});
const workspace = { init: vi.fn(async () => {}), destroy: vi.fn(async () => {}) };
(createSandbox as Mock).mockResolvedValue({ id: 'sandbox-1' });
(createWorkspace as Mock).mockReturnValue(workspace);
(setupSandboxWorkspace as Mock).mockResolvedValue(undefined);
const creation = service.getOrCreateWorkspace('thread-1', fakeUser, {} as InstanceAiContext);
await vi.waitFor(() => expect(resolveDaytonaConfig).toHaveBeenCalledTimes(1));
service.invalidateCachedWorkspaces();
resolveFirstConfig({ apiKey: 'old-key' });
await creation;
expect(resolveDaytonaConfig).toHaveBeenCalledTimes(2);
expect(createSandbox).toHaveBeenCalledTimes(1);
expect(createSandbox).toHaveBeenCalledWith(
expect.objectContaining({ daytonaApiKey: 'new-key' }),
expect.any(Object),
);
});
it('does not cache a workspace created across a settings change', async () => {
const { service } = createSandboxService({
config: { sandboxEnabled: true, sandboxProvider: 'daytona' },
});
let resolveFirst!: (sandbox: unknown) => void;
const firstSandbox = new Promise((resolve) => {
resolveFirst = resolve;
});
const firstWorkspace = { init: vi.fn(async () => {}), destroy: vi.fn(async () => {}) };
const secondWorkspace = { init: vi.fn(async () => {}), destroy: vi.fn(async () => {}) };
(createSandbox as Mock)
.mockReturnValueOnce(firstSandbox)
.mockResolvedValueOnce({ id: 'sandbox-2' });
(createWorkspace as Mock).mockImplementation((sandbox: { id: string }) =>
sandbox.id === 'sandbox-1' ? firstWorkspace : secondWorkspace,
);
(setupSandboxWorkspace as Mock).mockResolvedValue(undefined);
const first = service.getOrCreateWorkspace('thread-1', fakeUser, {} as InstanceAiContext);
await vi.waitFor(() => expect(createSandbox).toHaveBeenCalledTimes(1));
service.invalidateCachedWorkspaces();
const second = await service.getOrCreateWorkspace(
'thread-1',
fakeUser,
{} as InstanceAiContext,
);
resolveFirst({ id: 'sandbox-1' });
await first;
const reused = await service.getOrCreateWorkspace(
'thread-1',
fakeUser,
{} as InstanceAiContext,
);
expect(reused).toBe(second);
expect(createSandbox).toHaveBeenCalledTimes(2);
});
it('destroys the workspace when sandbox startup fails', async () => {
const { service } = createSandboxService({
config: { sandboxEnabled: true, sandboxProvider: 'daytona' },
File diff suppressed because it is too large Load Diff
@@ -96,6 +96,7 @@ function scopeOf(handlerName: string): { scope: Scope; globalOnly: boolean } | u
describe('InstanceAiController', () => {
const instanceAiService = mock<InstanceAiService>();
const gatewayService = mock<InstanceAiGatewayService>();
const browserSessionService = mock<InstanceAiBrowserSessionService>();
const memoryService = mock<InstanceAiMemoryService>();
const settingsService = mock<InstanceAiSettingsService>();
const eventBus = mock<InProcessEventBus>();
@@ -122,7 +123,7 @@ describe('InstanceAiController', () => {
const controller = new InstanceAiController(
instanceAiService,
gatewayService,
mock<InstanceAiBrowserSessionService>(),
browserSessionService,
memoryService,
settingsService,
mock<EvalExecutionService>(),
@@ -986,13 +987,68 @@ describe('InstanceAiController', () => {
});
});
it('should wait for the settings reload to be published', async () => {
settingsService.updateAdminSettings.mockResolvedValue({ enabled: true } as never);
publisher.publishCommand.mockRejectedValue(new Error('publish failed'));
it('should publish and attempt every local side effect when one fails', async () => {
const refreshError = new Error('refresh failed');
settingsService.updateAdminSettings.mockResolvedValue({
enabled: false,
browserUseEnabled: false,
localGatewayDisabled: true,
} as never);
moduleRegistry.refreshModuleSettings.mockRejectedValueOnce(refreshError);
gatewayService.disconnectAllGateways.mockReturnValue([]);
await expect(controller.updateAdminSettings(req, res, { enabled: true })).rejects.toThrow(
'publish failed',
await expect(
controller.updateAdminSettings(req, res, { enabled: false }),
).resolves.toBeDefined();
expect(publisher.publishCommand).toHaveBeenCalledWith({
command: 'reload-instance-ai-settings',
});
expect(browserSessionService.shutdown).toHaveBeenCalled();
expect(gatewayService.disconnectAllGateways).toHaveBeenCalled();
expect(instanceAiErrorReporter.report).toHaveBeenCalledWith(refreshError, {
component: 'settings-side-effects',
threadId: 'admin-settings',
});
});
it('should report a publish failure after applying committed settings', async () => {
const committedSettings = {
enabled: false,
browserUseEnabled: false,
localGatewayDisabled: true,
};
const publishError = new Error('publish failed');
settingsService.updateAdminSettings.mockResolvedValue(committedSettings as never);
publisher.publishCommand.mockRejectedValue(publishError);
gatewayService.disconnectAllGateways.mockReturnValue([]);
await expect(controller.updateAdminSettings(req, res, { enabled: false })).resolves.toBe(
committedSettings,
);
expect(instanceAiErrorReporter.report).toHaveBeenCalledWith(publishError, {
component: 'settings-publish',
threadId: 'admin-settings',
});
expect(moduleRegistry.refreshModuleSettings).toHaveBeenCalledWith('instance-ai');
expect(browserSessionService.shutdown).toHaveBeenCalled();
expect(gatewayService.disconnectAllGateways).toHaveBeenCalled();
});
});
describe('reloadAdminSettings', () => {
it('should apply side effects from the reloaded local flags', async () => {
settingsService.isInstanceAiEnabled.mockReturnValue(false);
settingsService.isBrowserUseEnabled.mockReturnValue(false);
settingsService.isLocalGatewayDisabled.mockReturnValue(true);
gatewayService.disconnectAllGateways.mockReturnValue([]);
await controller.reloadAdminSettings();
expect(settingsService.reloadFromDb).toHaveBeenCalled();
expect(settingsService.getAdminSettings).not.toHaveBeenCalled();
expect(moduleRegistry.refreshModuleSettings).toHaveBeenCalledWith('instance-ai');
expect(browserSessionService.shutdown).toHaveBeenCalled();
expect(gatewayService.disconnectAllGateways).toHaveBeenCalled();
});
});
@@ -1040,15 +1096,6 @@ describe('InstanceAiController', () => {
});
});
describe('listModelCredentials', () => {
it('should require instanceAi:message scope', () => {
expect(scopeOf('listModelCredentials')).toEqual({
scope: 'instanceAi:message',
globalOnly: true,
});
});
});
describe('listServiceCredentials', () => {
it('should require instanceAi:manage scope', () => {
expect(scopeOf('listServiceCredentials')).toEqual({
File diff suppressed because it is too large Load Diff
@@ -2160,7 +2160,7 @@ export class InstanceAiAdapterService {
let searchResolved = false;
const lazySearch: InstanceAiWebResearchService['search'] = async (query, options) => {
if (!searchResolved) {
const config = await settingsService.resolveSearchConfig(user);
const config = await settingsService.resolveSearchConfig();
resolvedSearchMethod = this.buildSearchMethod(
config.braveApiKey ?? '',
config.searxngUrl ?? '',
@@ -674,13 +674,22 @@ export class InstanceAiController {
@Put('/settings')
@GlobalScope('instanceAi:manage')
async updateAdminSettings(
_req: AuthenticatedRequest,
req: AuthenticatedRequest,
_res: Response,
@Body payload: InstanceAiAdminSettingsUpdateRequest,
) {
const result = await this.settingsService.updateAdminSettings(payload);
await this.applyAdminSettingsSideEffects(result);
await this.publisher.publishCommand({ command: 'reload-instance-ai-settings' });
const result = await this.settingsService.updateAdminSettings(payload, req.user);
const [publishResult] = await Promise.allSettled([
this.publisher.publishCommand({ command: 'reload-instance-ai-settings' }),
this.applyAdminSettingsSideEffects(result),
]);
if (publishResult.status === 'rejected') {
this.instanceAiErrorReporter.report(publishResult.reason, {
component: 'settings-publish',
threadId: 'admin-settings',
});
}
return result;
}
@@ -688,32 +697,56 @@ export class InstanceAiController {
@OnPubSubEvent('reload-instance-ai-settings', { instanceType: 'main' })
async reloadAdminSettings() {
await this.settingsService.reloadFromDb();
await this.applyAdminSettingsSideEffects(await this.settingsService.getAdminSettings());
await this.applyAdminSettingsSideEffects({
enabled: this.settingsService.isInstanceAiEnabled(),
browserUseEnabled: this.settingsService.isBrowserUseEnabled(),
localGatewayDisabled: this.settingsService.isLocalGatewayDisabled(),
});
}
private async applyAdminSettingsSideEffects(settings: InstanceAiAdminSettingsResponse) {
await this.moduleRegistry.refreshModuleSettings('instance-ai');
private async applyAdminSettingsSideEffects(
settings: Pick<
InstanceAiAdminSettingsResponse,
'enabled' | 'browserUseEnabled' | 'localGatewayDisabled'
>,
) {
const sideEffects: Array<() => Promise<void> | void> = [
async () => {
await this.moduleRegistry.refreshModuleSettings('instance-ai');
},
];
if (!settings.enabled || !settings.browserUseEnabled) {
await this.browserSessionService.shutdown();
sideEffects.push(async () => await this.browserSessionService.shutdown());
}
if (settings.enabled && !settings.localGatewayDisabled) return;
if (!settings.enabled || settings.localGatewayDisabled) {
sideEffects.push(() => {
const disconnectedUserIds = this.gatewayService.disconnectAllGateways();
if (disconnectedUserIds.length === 0) return;
this.push.sendToUsers(
{
type: 'instanceAiGatewayStateChanged',
data: {
connected: false,
directory: null,
hostIdentifier: null,
toolCategories: [],
},
},
disconnectedUserIds,
);
});
}
const disconnectedUserIds = this.gatewayService.disconnectAllGateways();
if (disconnectedUserIds.length === 0) return;
this.push.sendToUsers(
{
type: 'instanceAiGatewayStateChanged',
data: {
connected: false,
directory: null,
hostIdentifier: null,
toolCategories: [],
},
},
disconnectedUserIds,
);
const results = await Promise.allSettled(sideEffects.map(async (apply) => await apply()));
for (const result of results) {
if (result.status === 'rejected') {
this.instanceAiErrorReporter.report(result.reason, {
component: 'settings-side-effects',
threadId: 'admin-settings',
});
}
}
}
// ── User preferences (per-user, self-service) ──────────────────────────
@@ -738,16 +771,10 @@ export class InstanceAiController {
return result;
}
@Get('/settings/credentials')
@GlobalScope('instanceAi:message')
async listModelCredentials(req: AuthenticatedRequest) {
return await this.settingsService.listModelCredentials(req.user);
}
@Get('/settings/service-credentials')
@GlobalScope('instanceAi:manage')
async listServiceCredentials(req: AuthenticatedRequest) {
return await this.settingsService.listServiceCredentials(req.user);
async listServiceCredentials(_req: AuthenticatedRequest) {
return await this.settingsService.listInstanceServiceCredentials();
}
@Get('/settings/model-credentials')
@@ -19,11 +19,19 @@ export class InstanceAiModule implements ModuleInterface {
const { InstanceCredentialBroker } = await import(
'@/credentials/instance-credential-broker.js'
);
const { InstanceAiSettingsService, INSTANCE_AI_MODEL_CREDENTIAL_POLICY } = await import(
'./instance-ai-settings.service.js'
);
const {
InstanceAiSettingsService,
INSTANCE_AI_MODEL_CREDENTIAL_POLICY,
INSTANCE_AI_DAYTONA_CREDENTIAL_POLICY,
INSTANCE_AI_N8N_SANDBOX_CREDENTIAL_POLICY,
INSTANCE_AI_SEARCH_CREDENTIAL_POLICY,
} = await import('./instance-ai-settings.service.js');
const settingsService = Container.get(InstanceAiSettingsService);
Container.get(InstanceCredentialBroker).registerUse(INSTANCE_AI_MODEL_CREDENTIAL_POLICY);
const credentialBroker = Container.get(InstanceCredentialBroker);
credentialBroker.registerUse(INSTANCE_AI_MODEL_CREDENTIAL_POLICY);
credentialBroker.registerUse(INSTANCE_AI_DAYTONA_CREDENTIAL_POLICY);
credentialBroker.registerUse(INSTANCE_AI_N8N_SANDBOX_CREDENTIAL_POLICY);
credentialBroker.registerUse(INSTANCE_AI_SEARCH_CREDENTIAL_POLICY);
await settingsService.loadFromDb();
await import('./instance-ai.controller.js');
await import('./mcp/instance-ai-mcp-connection.controller.js');
@@ -696,13 +696,10 @@ export class InstanceAiService {
this._ssrfProtectionConfig = ssrfProtectionConfig;
this._ssrfProtectionService = ssrfProtectionService;
// When the admin changes MCP settings, tear down existing clients so the
// next agent run rebuilds them against the new config. In-flight tool
// calls on disconnected clients will fail — that's accepted: the
// alternative is leaking clients keyed by stale config until shutdown.
// We only listen for the MCP-changed flag so unrelated settings saves
// don't churn live MCP connections.
// Runtime clients capture provider settings at creation, so rebuild them
// after admin settings change. In-flight sandbox users retain their entry.
this.eventService.on('instance-ai-settings-updated', ({ mcpSettingsChanged }) => {
this.sandboxService.invalidateCachedWorkspaces();
if (!mcpSettingsChanged) return;
if (!this._mcpClientManager) return;
this._mcpClientManager.disconnect().catch((error: unknown) => {
@@ -1,3 +1,5 @@
import { createHash } from 'node:crypto';
import type { InstanceAiConfig } from '@n8n/config';
import type { User } from '@n8n/db';
import {
@@ -27,12 +29,19 @@ const DEFAULT_SANDBOX_TTL_MS = 15 * 60 * 1000;
export type RuntimeSandboxEntry = {
sandbox: NonNullable<Awaited<ReturnType<typeof createSandbox>>>;
workspace: NonNullable<ReturnType<typeof createWorkspace>>;
configFingerprint: string;
setupComplete: boolean;
setupPromise: Promise<void> | undefined;
expiresAt: number;
cleanupTimer?: ReturnType<typeof setTimeout>;
};
type SandboxCacheState = { fingerprint: string; config?: SandboxConfig };
function sandboxConfigFingerprint(config: object): string {
return createHash('sha256').update(JSON.stringify(config)).digest('hex');
}
function slugifySandboxName(value: string, maxLen: number): string {
const slug = value
.toLowerCase()
@@ -107,8 +116,8 @@ export type InstanceAiSandboxBackgroundTasks = {
/** Settings collaborator that resolves provider credentials from admin config. */
export type InstanceAiSandboxSettings = {
resolveDaytonaConfig: (user: User) => Promise<{ apiUrl?: string; apiKey?: string }>;
resolveN8nSandboxConfig: (user: User) => Promise<{ serviceUrl?: string; apiKey?: string }>;
resolveDaytonaConfig: () => Promise<{ apiUrl?: string; apiKey?: string }>;
resolveN8nSandboxConfig: () => Promise<{ serviceUrl?: string; apiKey?: string }>;
};
/** Proxy collaborator that routes Daytona traffic through the AI assistant service. */
@@ -154,7 +163,12 @@ export class InstanceAiSandboxService {
private readonly sandboxes = new Map<string, RuntimeSandboxEntry>();
/** In-flight runtime workspace creations keyed by thread ID. */
private readonly sandboxCreations = new Map<string, Promise<RuntimeSandboxEntry | undefined>>();
private readonly sandboxCreations = new Map<
string,
{ fingerprint: string; promise: Promise<RuntimeSandboxEntry | undefined> }
>();
private cacheGeneration = 0;
constructor(private readonly options: InstanceAiSandboxServiceOptions) {}
@@ -184,7 +198,9 @@ export class InstanceAiSandboxService {
sandboxAutoDeleteMinutes,
daytonaTokenRefreshSkewMs,
} = this.instanceAiConfig;
const provider = normalizeSandboxProvider(sandboxProvider);
const provider = this.options.aiService.isProxyEnabled()
? 'daytona'
: normalizeSandboxProvider(sandboxProvider);
if (!sandboxEnabled) {
return {
enabled: false,
@@ -259,14 +275,14 @@ export class InstanceAiSandboxService {
}
// Direct mode: Daytona credentials from env vars or admin credential
const daytona = await this.options.settingsService.resolveDaytonaConfig(user);
const daytona = await this.options.settingsService.resolveDaytonaConfig();
return {
...base,
daytonaApiUrl: daytona.apiUrl ?? base.daytonaApiUrl,
daytonaApiKey: daytona.apiKey ?? base.daytonaApiKey,
};
}
const sandbox = await this.options.settingsService.resolveN8nSandboxConfig(user);
const sandbox = await this.options.settingsService.resolveN8nSandboxConfig();
return {
...base,
serviceUrl: sandbox.serviceUrl ?? base.serviceUrl,
@@ -278,9 +294,17 @@ export class InstanceAiSandboxService {
threadId: string,
user: User,
): Promise<RuntimeSandboxEntry | undefined> {
const cacheGeneration = this.cacheGeneration;
const cacheState = await this.resolveSandboxCacheState(user);
if (cacheGeneration !== this.cacheGeneration) {
return await this.getOrCreateWorkspaceEntry(threadId, user);
}
const existing = this.sandboxes.get(threadId);
if (existing) {
if (this.isSandboxEntryExpired(existing) && !this.isSandboxInUse(threadId)) {
if (
existing.configFingerprint !== cacheState.fingerprint ||
(this.isSandboxEntryExpired(existing) && !this.isSandboxInUse(threadId))
) {
this.evictSandboxEntry(threadId, existing);
} else {
this.touchSandboxEntry(threadId, existing);
@@ -289,14 +313,26 @@ export class InstanceAiSandboxService {
}
const pending = this.sandboxCreations.get(threadId);
if (pending) return await pending;
if (pending?.fingerprint === cacheState.fingerprint) return await pending.promise;
const creation = this.createWorkspaceEntry(threadId, user);
this.sandboxCreations.set(threadId, creation);
const creation = this.createWorkspaceEntry(threadId, user, cacheState);
const pendingCreation = { fingerprint: cacheState.fingerprint, promise: creation };
this.sandboxCreations.set(threadId, pendingCreation);
try {
return await creation;
const entry = await creation;
if (
entry &&
cacheGeneration === this.cacheGeneration &&
this.sandboxCreations.get(threadId) === pendingCreation
) {
this.sandboxes.set(threadId, entry);
this.scheduleSandboxExpiry(threadId, entry);
}
return entry;
} finally {
this.sandboxCreations.delete(threadId);
if (this.sandboxCreations.get(threadId) === pendingCreation) {
this.sandboxCreations.delete(threadId);
}
}
}
@@ -331,8 +367,12 @@ export class InstanceAiSandboxService {
private async createWorkspaceEntry(
threadId: string,
user: User,
cacheState: SandboxCacheState,
): Promise<RuntimeSandboxEntry | undefined> {
const config = withThreadScopedSandboxIdentity(await this.resolveSandboxConfig(user), threadId);
const config = withThreadScopedSandboxIdentity(
cacheState.config ?? (await this.resolveSandboxConfig(user)),
threadId,
);
if (!config.enabled) return undefined;
const sandbox = await createSandbox(config, {
@@ -356,15 +396,36 @@ export class InstanceAiSandboxService {
const entry: RuntimeSandboxEntry = {
sandbox,
workspace,
configFingerprint: cacheState.fingerprint,
setupComplete: false,
setupPromise: undefined,
expiresAt: this.nextSandboxExpiry(),
};
this.sandboxes.set(threadId, entry);
this.scheduleSandboxExpiry(threadId, entry);
return entry;
}
private async resolveSandboxCacheState(user: User): Promise<SandboxCacheState> {
if (this.options.aiService.isProxyEnabled()) {
return {
fingerprint: sandboxConfigFingerprint({
mode: 'proxy',
config: this.getSandboxConfigFromEnv(),
}),
};
}
const config = await this.resolveSandboxConfig(user);
return { config, fingerprint: sandboxConfigFingerprint(config) };
}
invalidateCachedWorkspaces(): void {
this.cacheGeneration++;
this.sandboxCreations.clear();
for (const [threadId, entry] of this.sandboxes) {
this.evictSandboxEntry(threadId, entry);
}
}
private evictSandboxEntry(threadId: string, entry: RuntimeSandboxEntry): void {
if (this.sandboxes.get(threadId) !== entry) return;
@@ -0,0 +1,190 @@
import { testDb } from '@n8n/backend-test-utils';
import type { User } from '@n8n/db';
import {
CredentialsRepository,
InstanceCredentialAssignmentRepository,
SettingsRepository,
} from '@n8n/db';
import { Container } from '@n8n/di';
import { CREDENTIAL_BLANKING_VALUE } from 'n8n-workflow';
import { CredentialsService } from '@/credentials/credentials.service';
import { InstanceCredentialBroker } from '@/credentials/instance-credential-broker';
import {
INSTANCE_AI_DAYTONA_CREDENTIAL_POLICY,
INSTANCE_AI_MODEL_CREDENTIAL_POLICY,
INSTANCE_AI_N8N_SANDBOX_CREDENTIAL_POLICY,
INSTANCE_AI_SEARCH_CREDENTIAL_POLICY,
InstanceAiSettingsService,
} from '@/modules/instance-ai/instance-ai-settings.service';
import { createOwner } from './shared/db/users';
import { initCredentialsTypes } from './shared/utils';
describe('InstanceAiSettingsService (integration)', () => {
let service: InstanceAiSettingsService;
let credentialsRepository: CredentialsRepository;
let assignmentRepository: InstanceCredentialAssignmentRepository;
let settingsRepository: SettingsRepository;
let owner: User;
const modelUpdate = {
modelConnection: { type: 'openAiApi', data: { apiKey: 'test-key' } },
modelName: 'gpt-5',
};
beforeAll(async () => {
await testDb.init();
await initCredentialsTypes();
// The instance-ai module is not booted here; register its credential uses directly.
const broker = Container.get(InstanceCredentialBroker);
broker.registerUse(INSTANCE_AI_MODEL_CREDENTIAL_POLICY);
broker.registerUse(INSTANCE_AI_DAYTONA_CREDENTIAL_POLICY);
broker.registerUse(INSTANCE_AI_N8N_SANDBOX_CREDENTIAL_POLICY);
broker.registerUse(INSTANCE_AI_SEARCH_CREDENTIAL_POLICY);
service = Container.get(InstanceAiSettingsService);
credentialsRepository = Container.get(CredentialsRepository);
assignmentRepository = Container.get(InstanceCredentialAssignmentRepository);
settingsRepository = Container.get(SettingsRepository);
owner = await createOwner();
});
afterEach(async () => {
vi.restoreAllMocks();
await testDb.truncate(['InstanceCredentialAssignment', 'CredentialsEntity', 'Settings']);
});
afterAll(async () => {
await testDb.terminate();
});
it('saves the credential, assignment, and settings together', async () => {
const result = await service.updateAdminSettings(modelUpdate, owner);
expect(result.modelCredentialId).toEqual(expect.any(String));
expect(result.modelName).toBe('gpt-5');
const credentials = await credentialsRepository.findBy({ usageScope: 'instance' });
expect(credentials).toHaveLength(1);
expect(credentials[0].id).toBe(result.modelCredentialId);
await expect(
assignmentRepository.findAssignedCredentialId(INSTANCE_AI_MODEL_CREDENTIAL_POLICY.id),
).resolves.toBe(result.modelCredentialId);
const persisted = await settingsRepository.findByKey('instanceAi.settings');
expect(persisted?.value).toContain('"modelName":"gpt-5"');
});
it('rolls back the credential and assignment when the settings write fails', async () => {
vi.spyOn(settingsRepository, 'upsertByKey').mockRejectedValueOnce(
new Error('settings write failed'),
);
await expect(service.updateAdminSettings(modelUpdate, owner)).rejects.toThrow(
'settings write failed',
);
await expect(credentialsRepository.findBy({ usageScope: 'instance' })).resolves.toHaveLength(0);
await expect(
assignmentRepository.findAssignedCredentialId(INSTANCE_AI_MODEL_CREDENTIAL_POLICY.id),
).resolves.toBeNull();
await expect(settingsRepository.findByKey('instanceAi.settings')).resolves.toBeNull();
});
it('keeps the previous credential when a replacing update fails at the settings write', async () => {
const credential = await Container.get(CredentialsService).createInstanceCredential(
{
name: 'AI Assistant model',
type: 'openAiApi',
data: { apiKey: 'test-key' },
usageScope: 'instance',
},
owner,
{},
);
const credentialId = credential.id;
await assignmentRepository.assignCredential(
INSTANCE_AI_MODEL_CREDENTIAL_POLICY.id,
credentialId,
INSTANCE_AI_MODEL_CREDENTIAL_POLICY.credentialTypes,
);
await settingsRepository.upsertByKey(
'instanceAi.settings',
JSON.stringify({ modelName: 'gpt-5' }),
true,
{},
);
const before = await credentialsRepository.findOneByOrFail({ id: credentialId });
vi.spyOn(settingsRepository, 'upsertByKey').mockRejectedValueOnce(
new Error('settings write failed'),
);
await expect(
service.updateAdminSettings(
{
modelConnection: { type: 'openAiApi', data: { apiKey: 'rotated-key' } },
modelName: 'gpt-5',
},
owner,
),
).rejects.toThrow('settings write failed');
const after = await credentialsRepository.findOneByOrFail({ id: credentialId });
expect(after.data).toBe(before.data);
await expect(
assignmentRepository.findAssignedCredentialId(INSTANCE_AI_MODEL_CREDENTIAL_POLICY.id),
).resolves.toBe(credentialId);
});
it('keeps stored secrets when updating a redacted connection', async () => {
const created = await service.updateAdminSettings(modelUpdate, owner);
const updated = await service.updateAdminSettings(
{
modelConnection: {
type: 'openAiApi',
data: { apiKey: CREDENTIAL_BLANKING_VALUE },
},
modelName: 'gpt-5.1',
},
owner,
);
expect(updated.modelCredentialId).toBe(created.modelCredentialId);
const credential = await credentialsRepository.findOneByOrFail({
id: created.modelCredentialId!,
});
await expect(
Container.get(CredentialsService).decrypt(credential, true),
).resolves.toMatchObject({
apiKey: 'test-key',
});
});
it('does not update workflow credentials through the instance-scoped repository update', async () => {
const workflowCredential = await credentialsRepository.save(
credentialsRepository.create({
name: 'Workflow credential',
type: 'openAiApi',
data: 'workflow-encrypted',
usageScope: 'project',
}),
);
const updated = await credentialsRepository.updateInstanceCredential(
workflowCredential.id,
{
id: workflowCredential.id,
name: 'Hijacked',
type: workflowCredential.type,
data: 'other-encrypted',
},
{},
);
expect(updated).toBeNull();
await expect(
credentialsRepository.findOneByOrFail({ id: workflowCredential.id }),
).resolves.toMatchObject({ name: 'Workflow credential', data: 'workflow-encrypted' });
});
});
@@ -11,6 +11,19 @@ describe('N8nSettingsPageHeader', () => {
expect(title.tagName).toBe('H1');
});
it('renders the titleTrailing slot alongside the title', () => {
render(N8nSettingsPageHeader, {
props: { title: 'This instance', showDocsLink: false },
slots: { titleTrailing: '<span data-test-id="trailing">Preview</span>' },
});
const title = screen.getByText('This instance');
const trailing = screen.getByTestId('trailing');
expect(title).toBeInTheDocument();
expect(trailing).toHaveTextContent('Preview');
expect(title.parentElement).toBe(trailing.parentElement);
});
it('renders the title with the requested heading tag', () => {
render(N8nSettingsPageHeader, {
props: { title: 'This instance', headingTag: 'h2', showDocsLink: false },
@@ -55,7 +55,13 @@ if (import.meta.env.DEV) {
<template>
<header :class="$style.header">
<N8nHeading :tag="headingTag" :class="$style.title" step="xl" color="text-dark">
<div v-if="slots.titleTrailing" :class="$style.titleRow">
<N8nHeading :tag="headingTag" :class="$style.title" step="xl" color="text-dark">
{{ title }}
</N8nHeading>
<slot name="titleTrailing" />
</div>
<N8nHeading v-else :tag="headingTag" :class="$style.title" step="xl" color="text-dark">
{{ title }}
</N8nHeading>
<p v-if="hasDescription || showDocsLink" :class="$style.description">
@@ -104,6 +110,12 @@ if (import.meta.env.DEV) {
letter-spacing: var(--letter-spacing--tight);
}
.titleRow {
display: flex;
align-items: center;
gap: var(--spacing--2xs);
}
.description {
margin: 0;
display: inline;
+79 -68
View File
@@ -5008,7 +5008,8 @@
"settings.n8nConnect.wallet.topUp": "Top up balance",
"settings.n8nConnect.usage.refresh.tooltip": "Refresh usage records",
"settings.n8nAgent": "AI Assistant",
"settings.n8nAgent.description": "Configure AI Assistant for everyone on this instance",
"settings.n8nAgent.description": "Control how the AI Assistant runs on this instance and what data it can use.",
"settings.n8nAgent.docsLabel": "AI Assistant docs",
"settings.agentBuilder.title": "Agents",
"settings.agentBuilder.description": "Choose which model the agent builder runs on. By default it uses the n8n AI assistant; admins can switch to a custom provider and credential.",
"settings.agentBuilder.section.model": "Builder model",
@@ -5035,46 +5036,95 @@
"agents.modelSelector.freeCredits.badge": "free credits",
"agents.modelSelector.freeCredits.description": "Get {credits} free OpenAI API credits. Try it with gpt-5-mini.",
"settings.n8nAgent.enable.label": "Enable AI Assistant",
"settings.n8nAgent.enable.description": "Enables the feature for all users in the instance",
"settings.n8nAgent.status.label": "AI Assistant status",
"settings.n8nAgent.status.description": "Let users chat with AI Assistant to build and manage workflows",
"settings.n8nAgent.enable.description": "When enabled, everyone on this instance can use it.",
"settings.n8nAgent.status.enabled": "Enabled",
"settings.n8nAgent.status.setupRequired": "Setup required",
"settings.n8nAgent.status.enable": "Enable",
"settings.n8nAgent.status.manage": "Manage AI Assistant status",
"settings.n8nAgent.status.disable": "Disable",
"settings.n8nAgent.status.disable.title": "Disable AI Assistant?",
"settings.n8nAgent.status.disable.description": "This prevents everyone on this instance from using AI Assistant. Existing conversations won't be deleted. You can turn it back on later.",
"settings.n8nAgent.empty.title": "Build and manage workflows with AI Assistant",
"settings.n8nAgent.empty.description": "Let users ask AI Assistant to create, update, and run workflows across this instance.",
"settings.n8nAgent.empty.title": "Build automations by describing them",
"settings.n8nAgent.empty.description": "Describe what you want to automate. The AI Assistant plans, builds, tests, and fixes workflows and AI agents in your projects.",
"settings.n8nAgent.empty.enable": "Enable AI Assistant",
"settings.n8nAgent.credentials.label": "AI service credentials",
"settings.n8nAgent.credentials.description": "Connect the model, sandbox, and web search services AI Assistant uses",
"settings.n8nAgent.credentials.title": "AI service credentials",
"settings.n8nAgent.credentials.pageDescription": "Create or select provider connections for the services AI Assistant uses. These connections aren't available in workflows.",
"settings.n8nAgent.credentials.back": "Back to AI Assistant",
"settings.n8nAgent.credentials.edit": "Edit",
"settings.n8nAgent.credentials.model.title": "Model",
"settings.n8nAgent.credentials.model.description": "Choose the model provider credential used for everyone on this instance",
"settings.n8nAgent.credentials.unavailable.title": "Credential settings aren't available",
"settings.n8nAgent.credentials.unavailable.description": "This deployment manages AI Assistant credentials outside n8n.",
"settings.n8nAgent.empty.footnote": "You control permissions, MCP access, and what data is sent to the model after enabling.",
"settings.n8nAgent.capabilities.title": "Capabilities",
"settings.n8nAgent.capabilities.description": "Choose which tools users can connect to AI Assistant",
"settings.n8nAgent.capabilities.description": "The AI Assistant works without these, with fewer abilities.",
"settings.n8nAgent.computerUse.label": "Computer use",
"settings.n8nAgent.computerUse.description": "Let users connect AI Assistant to their computer to work with files and the browser",
"settings.n8nAgent.computerUse.disabled.warning": "Computer use has been disabled by your administrator",
"settings.n8nAgent.browserUse.label": "Browser use",
"settings.n8nAgent.browserUse.description": "Let users connect AI Assistant to their browser to view pages and automate tasks",
"settings.n8nAgent.mcpAccess.label": "MCP server access",
"settings.n8nAgent.mcpAccess.description": "Let users extend AI Assistant with MCP servers from the registry",
"settings.n8nAgent.modelCredential.label": "Model credential",
"settings.n8nAgent.modelCredential.description": "Choose a provider credential or create one",
"settings.n8nAgent.modelCredential.placeholder": "Select a credential",
"settings.n8nAgent.modelCredential.none": "Use environment configuration",
"settings.n8nAgent.modelCredential.createNew": "Create credential",
"settings.n8nAgent.mcp.title": "MCP servers",
"settings.n8nAgent.mcp.description": "MCP servers add tools to the AI Assistant, like internal APIs or third party apps.",
"settings.n8nAgent.mcpAccess.label": "Let users connect MCP servers",
"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.value": "Environment configuration",
"settings.n8nAgent.modelCredential.add": "Connect",
"settings.n8nAgent.modelCredential.field": "Provider",
"settings.n8nAgent.modelCredential.placeholder": "Select a provider",
"settings.n8nAgent.connection.none": "Use environment configuration",
"settings.n8nAgent.connection.noneNoEnv": "Not connected",
"settings.n8nAgent.modelName.label": "Model ID",
"settings.n8nAgent.modelName.placeholder": "Enter a model ID",
"settings.n8nAgent.setup.step": "Step {step} of {total}",
"settings.n8nAgent.setup.continue": "Continue",
"settings.n8nAgent.setup.skip": "Skip",
"settings.n8nAgent.modelDialog.title": "Model connection",
"settings.n8nAgent.modelDialog.description": "Connect a model to power the AI Assistant on this instance.",
"settings.n8nAgent.modelDialog.setupTitle": "Connect a model",
"settings.n8nAgent.modelDialog.setupDescription": "The model powers the AI Assistant on this instance.",
"settings.n8nAgent.sandboxDialog.setupTitle": "Connect a code sandbox",
"settings.n8nAgent.modelDialog.footnote": "Connection details are stored securely on this instance and can't be used in workflows.",
"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.value": "Environment configuration",
"settings.n8nAgent.sandbox.add": "Add 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",
"settings.n8nAgent.sandboxDialog.provider": "Provider",
"settings.n8nAgent.sandboxDialog.providerHint": "The default comes from your environment configuration.",
"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.value": "Environment configuration",
"settings.n8nAgent.search.setup": "Set up",
"settings.n8nAgent.searchDialog.title": "Set up web search",
"settings.n8nAgent.searchDialog.setupTitle": "Set up web search (optional)",
"settings.n8nAgent.searchDialog.description": "Connect a search provider. The AI Assistant asks for permission before it accesses a domain.",
"settings.n8nAgent.searchCredential.label": "Provider",
"settings.n8nAgent.searchCredential.placeholder": "Select a provider",
"settings.n8nAgent.toast.saved": "Settings saved",
"settings.n8nAgent.toast.errorTitle": "Settings error",
"settings.n8nAgent.toast.loadError": "Failed to load settings",
"settings.n8nAgent.toast.saveError": "Failed to save settings",
"settings.n8nAgent.toast.preferenceError": "Failed to save preference",
"settings.n8nAgent.permissions.title": "Permissions",
"settings.n8nAgent.permissions.description": "Choose which actions AI Assistant can take automatically. These settings don't give users any extra permissions",
"settings.n8nAgent.permissions.alwaysAllow": "Always allow",
"settings.n8nAgent.permissions.needsApproval": "Needs approval",
"settings.n8nAgent.permissions.blocked": "Blocked",
"settings.n8nAgent.permissions.description": "Choose which actions the AI Assistant asks about before doing. It can never do more than the user it's working for: it uses their n8n role and project access.",
"settings.n8nAgent.permissions.group.workflows": "Workflows",
"settings.n8nAgent.permissions.group.folders": "Folders",
"settings.n8nAgent.permissions.group.dataTables": "Data tables",
"settings.n8nAgent.permissions.group.credentials": "Credentials",
"settings.n8nAgent.permissions.group.system": "System",
"settings.n8nAgent.permissions.group.web": "Web",
"settings.n8nAgent.permissions.group.mcp": "MCP tools",
"settings.n8nAgent.permissions.group.default": "Default",
"settings.n8nAgent.permissions.group.exception": "1 exception",
"settings.n8nAgent.permissions.group.exceptions": "{count} exceptions",
"settings.n8nAgent.permissions.group.mcpDisabled": "Users can't connect MCP servers",
"settings.n8nAgent.dataSharing.title": "Data sent to the model",
"settings.n8nAgent.dataSharing.description": "What n8n sends when you use the AI Assistant. n8n never trains models on your data. What's sent to the model is covered by your provider's terms.",
"settings.n8nAgent.dataSharing.manage.label": "AI usage settings",
"settings.n8nAgent.dataSharing.manage.description": "Controls whether n8n sends field names, types, and actual data values.",
"settings.n8nAgent.permissions.alwaysAllow": "Allow",
"settings.n8nAgent.permissions.needsApproval": "Ask first",
"settings.n8nAgent.permissions.blocked": "Block",
"settings.n8nAgent.permissions.createWorkflow": "Create workflows",
"settings.n8nAgent.permissions.updateWorkflow": "Update workflows",
"settings.n8nAgent.permissions.runWorkflow": "Run workflows",
@@ -6565,7 +6615,6 @@
"instanceAi.domainAccess.deny": "Deny",
"instanceAi.webSearch.prompt": "AI Assistant wants to search the web",
"instanceAi.webSearch.allowThread": "Always allow",
"instanceAi.settings.permissions.fetchUrl.label": "Fetch URLs without approval",
"instanceAi.credential.selected": "Credential selected",
"instanceAi.credential.required": "This action requires a credential",
"instanceAi.credential.allSelected": "All credentials configured",
@@ -6670,43 +6719,6 @@
"instanceAi.debug.threads.refresh": "Refresh",
"instanceAi.debug.threads.fetchError": "Failed to load thread data",
"instanceAi.debug.threads.messageCount": "{count} messages",
"instanceAi.settings.title": "Settings",
"instanceAi.settings.save": "Save",
"instanceAi.settings.reset": "Reset",
"instanceAi.settings.section.model": "Model",
"instanceAi.settings.section.sandbox": "Sandbox",
"instanceAi.settings.section.search": "Search",
"instanceAi.settings.section.advanced": "Advanced",
"instanceAi.settings.credential.label": "Credential",
"instanceAi.settings.credential.placeholder": "Select a credential...",
"instanceAi.settings.credential.none": "None (use environment variables)",
"instanceAi.settings.credential.createNew": "+ Create new credential",
"instanceAi.settings.credential.createBrave": "+ Create Brave Search credential",
"instanceAi.settings.credential.createSearxng": "+ Create SearXNG credential",
"instanceAi.settings.modelName.label": "Model name",
"instanceAi.settings.modelName.placeholder": "e.g. claude-sonnet-4-5, gpt-4o",
"instanceAi.settings.apiKey.set": "Set",
"instanceAi.settings.apiKey.notSet": "Not set",
"instanceAi.settings.sandboxEnabled.label": "Enable sandbox",
"instanceAi.settings.sandboxProvider.label": "Sandbox provider",
"instanceAi.settings.daytonaCredential.label": "Daytona credential",
"instanceAi.settings.n8nSandboxCredential.label": "n8n sandbox API key credential",
"instanceAi.settings.n8nSandbox.serviceUrlHint": "Set N8N_SANDBOX_SERVICE_URL in your environment to use the n8n Sandbox Service.",
"instanceAi.settings.sandboxImage.label": "Sandbox Docker image",
"instanceAi.settings.sandboxTimeout.label": "Sandbox timeout (ms)",
"instanceAi.settings.searchCredential.label": "Search credential",
"instanceAi.settings.browserMcp.label": "Chrome DevTools MCP",
"instanceAi.settings.mcpServers.label": "MCP servers",
"instanceAi.settings.mcpServers.placeholder": "name=url, comma-separated",
"instanceAi.settings.section.permissions": "Permissions",
"instanceAi.settings.permissions.description": "Toggle which actions require your approval before the agent executes them.",
"instanceAi.settings.permissions.createWorkflow.label": "Create workflow without approval",
"instanceAi.settings.permissions.updateWorkflow.label": "Update workflow without approval",
"instanceAi.settings.permissions.runWorkflow.label": "Run workflow without approval",
"instanceAi.settings.permissions.publishWorkflow.label": "Publish/unpublish workflow without approval",
"instanceAi.settings.permissions.deleteWorkflow.label": "Delete workflow without approval",
"instanceAi.settings.permissions.deleteCredential.label": "Delete credential without approval",
"instanceAi.settings.permissions.deleteDataTable.label": "Delete data table without approval",
"instanceAi.builderCard.title": "Building workflow",
"instanceAi.builderCard.phase.researching": "Researching nodes",
"instanceAi.builderCard.phase.schemas": "Loading schemas",
@@ -7056,7 +7068,6 @@
"instanceAi.filesystem.category.computerUse": "Computer",
"instanceAi.filesystem.category.filesystem": "Files",
"instanceAi.filesystem.category.shell": "Shell",
"instanceAi.settings.permissions.activateWorkflow.label": "Activate workflow without approval",
"instanceAi.tools.patch-workflow": "Updating workflow",
"agents.home.untitledAgent": "Untitled Agent",
"agents.relativeTime.justNow": "just now",
@@ -34,8 +34,8 @@ const mockFetchSettings = vi.fn();
const mockUpdateSettings = vi.fn();
const mockFetchPreferences = vi.fn();
const mockUpdatePreferences = vi.fn();
const mockFetchModelCredentials = vi.fn().mockResolvedValue([]);
const mockFetchServiceCredentials = vi.fn().mockResolvedValue([]);
const mockFetchInstanceModelCredentials = vi.fn().mockResolvedValue([]);
const mockCreateGatewayLink = vi.fn();
const mockDisconnectGatewaySession = vi.fn();
@@ -44,8 +44,8 @@ vi.mock('../instanceAi.settings.api', () => ({
updateSettings: (...args: unknown[]) => mockUpdateSettings(...args),
fetchPreferences: (...args: unknown[]) => mockFetchPreferences(...args),
updatePreferences: (...args: unknown[]) => mockUpdatePreferences(...args),
fetchModelCredentials: (...args: unknown[]) => mockFetchModelCredentials(...args),
fetchServiceCredentials: (...args: unknown[]) => mockFetchServiceCredentials(...args),
fetchInstanceModelCredentials: (...args: unknown[]) => mockFetchInstanceModelCredentials(...args),
}));
const mockGetGatewayStatus = vi.fn();
@@ -57,6 +57,7 @@ vi.mock('../instanceAi.api', () => ({
import { useInstanceAiSettingsStore } from '../instanceAiSettings.store';
import { useSettingsStore } from '@/app/stores/settings.store';
import { hasPermission } from '@/app/utils/rbac/permissions';
type InstanceAiModuleSettings = NonNullable<FrontendModuleSettings['instance-ai']>;
@@ -97,11 +98,34 @@ describe('useInstanceAiSettingsStore', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(hasPermission).mockReturnValue(false);
setActivePinia(createPinia());
store = useInstanceAiSettingsStore();
settingsStore = useSettingsStore();
});
describe('permissions', () => {
it('checks related admin scopes independently', () => {
vi.mocked(hasPermission)
.mockReturnValueOnce(true)
.mockReturnValueOnce(false)
.mockReturnValueOnce(false);
expect(store.canManage).toBe(true);
expect(store.canManageAiUsage).toBe(false);
expect(store.canManageInstanceCredentials).toBe(false);
expect(hasPermission).toHaveBeenNthCalledWith(1, ['rbac'], {
rbac: { scope: 'instanceAi:manage' },
});
expect(hasPermission).toHaveBeenNthCalledWith(2, ['rbac'], {
rbac: { scope: 'aiAssistant:manage' },
});
expect(hasPermission).toHaveBeenNthCalledWith(3, ['rbac'], {
rbac: { scope: 'credential:manageInstance' },
});
});
});
describe('isInstanceAiDisabled', () => {
it('returns true when module settings has enabled=false', () => {
setModuleSettings(settingsStore, {
@@ -256,10 +280,9 @@ describe('useInstanceAiSettingsStore', () => {
expect(store.isWorkflowBuilderAvailable).toBe(false);
expect(store.isSandboxEnabled).toBe(false);
expect(store.sandboxUnavailableReason).toBeNull();
});
it('exposes the sandbox unavailable reason from module settings', () => {
it('keeps the builder unavailable while the sandbox is enabled', () => {
setModuleSettings(settingsStore, {
sandboxEnabled: true,
workflowBuilderAvailable: false,
@@ -268,7 +291,51 @@ describe('useInstanceAiSettingsStore', () => {
expect(store.isWorkflowBuilderAvailable).toBe(false);
expect(store.isSandboxEnabled).toBe(true);
expect(store.sandboxUnavailableReason).toBe('N8N_SANDBOX_SERVICE_URL is required.');
});
});
describe('settings persistence', () => {
const response = {
enabled: true,
permissions: {},
mcpServers: '',
mcpAccessEnabled: true,
sandboxEnabled: false,
sandboxProvider: 'n8n-sandbox',
sandboxImage: '',
sandboxTimeout: 60,
daytonaCredentialId: null,
n8nSandboxCredentialId: null,
searchCredentialId: null,
modelCredentialId: null,
modelName: null,
modelEnvConfigured: false,
sandboxEnvConfigured: false,
searchEnvConfigured: false,
localGatewayDisabled: false,
};
beforeEach(() => {
setModuleSettings(settingsStore, { enabled: false });
mockUpdateSettings.mockResolvedValue(response);
settingsStore.getModuleSettings = vi.fn().mockRejectedValue(new Error('refresh failed'));
});
it('keeps a settings save successful when the module refresh fails', async () => {
store.setField('mcpAccessEnabled', true);
await expect(store.save()).resolves.toBe(true);
expect(store.settings).toEqual(response);
expect(store.draft).toEqual({});
expect(settingsStore.moduleSettings['instance-ai']?.enabled).toBe(true);
});
it('keeps an enablement save successful when the module refresh fails', async () => {
await expect(store.persistEnabled(true)).resolves.toBe(true);
expect(store.settings).toEqual(response);
expect(settingsStore.moduleSettings['instance-ai']?.enabled).toBe(true);
});
});
@@ -1,39 +0,0 @@
<script lang="ts" setup>
import { N8nHeading, N8nInput, N8nInputLabel } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { useSettingsField } from './useSettingsField';
const i18n = useI18n();
const { store, getString } = useSettingsField();
</script>
<template>
<div :class="$style.section">
<N8nHeading tag="h2" size="small">
{{ i18n.baseText('instanceAi.settings.section.advanced') }}
</N8nHeading>
<N8nInputLabel
:label="i18n.baseText('instanceAi.settings.mcpServers.label')"
:bold="false"
size="small"
>
<N8nInput
:model-value="getString('mcpServers')"
size="small"
type="textarea"
:rows="3"
:placeholder="i18n.baseText('instanceAi.settings.mcpServers.placeholder')"
@update:model-value="store.setField('mcpServers', $event)"
/>
</N8nInputLabel>
</div>
</template>
<style lang="scss" module>
.section {
display: flex;
flex-direction: column;
gap: var(--spacing--xs);
}
</style>
@@ -0,0 +1,691 @@
<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 };
const open = defineModel<boolean>('open', { required: true });
const props = withDefaults(defineProps<{ kind: InstanceAiConnectionKind; setup?: boolean }>(), {
setup: false,
});
const emit = defineEmits<{ saved: []; back: [] }>();
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]);
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);
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;
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') {
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 &&
!(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.
emit('saved');
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
: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 &&
(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>
</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>
@@ -0,0 +1,54 @@
<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>
@@ -1,69 +0,0 @@
<script lang="ts" setup>
import { computed } from 'vue';
import { N8nHeading, N8nInput, N8nSelect, N8nOption, N8nInputLabel } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { useSettingsField } from './useSettingsField';
const i18n = useI18n();
const { store, getPreferenceString } = useSettingsField();
const selectedCredentialId = computed(() => {
if (store.preferencesDraft.credentialId !== undefined)
return store.preferencesDraft.credentialId ?? '';
return store.preferences?.credentialId ?? '';
});
function handleCredentialChange(value: string | number | boolean | null) {
store.setPreferenceField('credentialId', value ? String(value) : null);
}
</script>
<template>
<div :class="$style.section">
<N8nHeading tag="h2" size="small">
{{ i18n.baseText('instanceAi.settings.section.model') }}
</N8nHeading>
<N8nInputLabel
:label="i18n.baseText('instanceAi.settings.credential.label')"
:bold="false"
size="small"
>
<N8nSelect
:model-value="selectedCredentialId"
size="small"
:placeholder="i18n.baseText('instanceAi.settings.credential.placeholder')"
@update:model-value="handleCredentialChange"
>
<N8nOption value="" :label="i18n.baseText('instanceAi.settings.credential.none')" />
<N8nOption
v-for="cred in store.credentials"
:key="cred.id"
:value="cred.id"
:label="`${cred.name} (${cred.provider})`"
/>
</N8nSelect>
</N8nInputLabel>
<N8nInputLabel
:label="i18n.baseText('instanceAi.settings.modelName.label')"
:bold="false"
size="small"
>
<N8nInput
:model-value="getPreferenceString('modelName')"
size="small"
:placeholder="i18n.baseText('instanceAi.settings.modelName.placeholder')"
@update:model-value="store.setPreferenceField('modelName', $event)"
/>
</N8nInputLabel>
</div>
</template>
<style lang="scss" module>
.section {
display: flex;
flex-direction: column;
gap: var(--spacing--xs);
}
</style>
@@ -1,259 +0,0 @@
<script lang="ts" setup>
import { computed, watch } from 'vue';
import {
N8nHeading,
N8nInput,
N8nInputNumber,
N8nSelect,
N8nOption,
N8nInputLabel,
} from '@n8n/design-system';
import { ElSwitch } from 'element-plus';
import { type BaseTextKey, useI18n } from '@n8n/i18n';
import { useUIStore } from '@/app/stores/ui.store';
import { CREDENTIAL_EDIT_MODAL_KEY } from '@/features/credentials/credentials.constants';
import { useSettingsField } from './useSettingsField';
const CREATE_NEW = '__create__';
const i18n = useI18n();
const uiStore = useUIStore();
const { store, getString, getNumber, getBool } = useSettingsField();
const provider = computed(() => {
return store.draft.sandboxProvider ?? store.settings?.sandboxProvider ?? 'n8n-sandbox';
});
const showDaytonaFields = computed(() => {
return provider.value === 'daytona';
});
const showN8nSandboxFields = computed(() => {
return provider.value === 'n8n-sandbox';
});
const showImageField = computed(() => {
return provider.value === 'daytona';
});
function handleProviderChange(value: string | number | boolean | null) {
if (value === 'n8n-sandbox' || value === 'daytona') {
store.setField('sandboxProvider', value);
}
}
const daytonaCredentials = computed(() =>
store.serviceCredentials.filter((c) => c.type === 'daytonaApi'),
);
const n8nSandboxCredentials = computed(() =>
store.serviceCredentials.filter((c) => c.type === 'httpHeaderAuth'),
);
const selectedDaytonaCredentialId = computed(() => {
if (store.draft.daytonaCredentialId !== undefined) return store.draft.daytonaCredentialId ?? '';
return store.settings?.daytonaCredentialId ?? '';
});
const selectedN8nSandboxCredentialId = computed(() => {
if (store.draft.n8nSandboxCredentialId !== undefined) {
return store.draft.n8nSandboxCredentialId ?? '';
}
return store.settings?.n8nSandboxCredentialId ?? '';
});
let creatingCredential = false;
let creatingCredentialType: 'daytonaApi' | 'httpHeaderAuth' | null = null;
function handleDaytonaCredentialChange(value: string | number | boolean | null) {
if (value === CREATE_NEW) {
creatingCredential = true;
creatingCredentialType = 'daytonaApi';
uiStore.openNewCredential('daytonaApi');
return;
}
store.setField('daytonaCredentialId', value ? String(value) : null);
}
function handleN8nSandboxCredentialChange(value: string | number | boolean | null) {
if (value === CREATE_NEW) {
creatingCredential = true;
creatingCredentialType = 'httpHeaderAuth';
uiStore.openNewCredential('httpHeaderAuth');
return;
}
store.setField('n8nSandboxCredentialId', value ? String(value) : null);
}
// Re-fetch credentials when the credential edit modal closes, auto-select if newly created
watch(
() => uiStore.isModalActiveById[CREDENTIAL_EDIT_MODAL_KEY],
async (isOpen, wasOpen) => {
if (!wasOpen || isOpen) return;
const previousIds = new Set(daytonaCredentials.value.map((c) => c.id));
const previousSandboxIds = new Set(n8nSandboxCredentials.value.map((c) => c.id));
await store.refreshCredentials();
if (creatingCredential) {
creatingCredential = false;
if (creatingCredentialType === 'daytonaApi') {
const newCred = daytonaCredentials.value.find((c) => !previousIds.has(c.id));
if (newCred) {
store.setField('daytonaCredentialId', newCred.id);
}
}
if (creatingCredentialType === 'httpHeaderAuth') {
const newCred = n8nSandboxCredentials.value.find((c) => !previousSandboxIds.has(c.id));
if (newCred) {
store.setField('n8nSandboxCredentialId', newCred.id);
}
}
creatingCredentialType = null;
}
},
);
</script>
<template>
<div :class="$style.section">
<N8nHeading tag="h2" size="small">
{{ i18n.baseText('instanceAi.settings.section.sandbox') }}
</N8nHeading>
<div :class="$style.switchRow">
<span :class="$style.switchLabel">{{
i18n.baseText('instanceAi.settings.sandboxEnabled.label')
}}</span>
<ElSwitch
:model-value="getBool('sandboxEnabled')"
@update:model-value="store.setField('sandboxEnabled', Boolean($event))"
/>
</div>
<N8nInputLabel
:label="i18n.baseText('instanceAi.settings.sandboxProvider.label')"
:bold="false"
size="small"
>
<N8nSelect :model-value="provider" size="small" @update:model-value="handleProviderChange">
<N8nOption value="n8n-sandbox" label="n8n Sandbox Service" />
<N8nOption value="daytona" label="Daytona" />
</N8nSelect>
</N8nInputLabel>
<template v-if="showDaytonaFields">
<N8nInputLabel
:label="i18n.baseText('instanceAi.settings.daytonaCredential.label')"
:bold="false"
size="small"
>
<N8nSelect
:model-value="selectedDaytonaCredentialId"
size="small"
:placeholder="i18n.baseText('instanceAi.settings.credential.placeholder')"
@update:model-value="handleDaytonaCredentialChange"
>
<N8nOption value="" :label="i18n.baseText('instanceAi.settings.credential.none')" />
<N8nOption
v-for="cred in daytonaCredentials"
:key="cred.id"
:value="cred.id"
:label="cred.name"
/>
<N8nOption
:value="CREATE_NEW"
:label="i18n.baseText('instanceAi.settings.credential.createNew')"
/>
</N8nSelect>
</N8nInputLabel>
</template>
<template v-if="showN8nSandboxFields">
<N8nInputLabel
:label="i18n.baseText('instanceAi.settings.n8nSandboxCredential.label' as BaseTextKey)"
:bold="false"
size="small"
>
<N8nSelect
:model-value="selectedN8nSandboxCredentialId"
size="small"
:placeholder="i18n.baseText('instanceAi.settings.credential.placeholder')"
@update:model-value="handleN8nSandboxCredentialChange"
>
<N8nOption value="" :label="i18n.baseText('instanceAi.settings.credential.none')" />
<N8nOption
v-for="cred in n8nSandboxCredentials"
:key="cred.id"
:value="cred.id"
:label="cred.name"
/>
<N8nOption
:value="CREATE_NEW"
:label="i18n.baseText('instanceAi.settings.credential.createNew')"
/>
</N8nSelect>
<p :class="$style.hint">
{{ i18n.baseText('instanceAi.settings.n8nSandbox.serviceUrlHint' as BaseTextKey) }}
</p>
</N8nInputLabel>
</template>
<N8nInputLabel
v-if="showImageField"
:label="i18n.baseText('instanceAi.settings.sandboxImage.label')"
:bold="false"
size="small"
>
<N8nInput
:model-value="getString('sandboxImage')"
size="small"
@update:model-value="store.setField('sandboxImage', $event)"
/>
</N8nInputLabel>
<N8nInputLabel
:label="i18n.baseText('instanceAi.settings.sandboxTimeout.label')"
:bold="false"
size="small"
>
<N8nInputNumber
:class="$style.numberInput"
:model-value="getNumber('sandboxTimeout') ?? 300000"
size="small"
:min="0"
:step="10000"
@update:model-value="store.setField('sandboxTimeout', $event ?? 300000)"
/>
</N8nInputLabel>
</div>
</template>
<style lang="scss" module>
.section {
display: flex;
flex-direction: column;
gap: var(--spacing--xs);
}
.numberInput {
max-width: 140px;
}
.switchRow {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--spacing--4xs) 0;
}
.switchLabel {
font-size: var(--font-size--2xs);
color: var(--color--text--tint-1);
}
.hint {
margin: var(--spacing--4xs) 0 0;
font-size: var(--font-size--2xs);
line-height: var(--line-height--sm);
color: var(--color--text--tint-1);
}
</style>
@@ -1,101 +0,0 @@
<script lang="ts" setup>
import { computed, watch } from 'vue';
import { N8nHeading, N8nSelect, N8nOption, N8nInputLabel } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { useUIStore } from '@/app/stores/ui.store';
import { CREDENTIAL_EDIT_MODAL_KEY } from '@/features/credentials/credentials.constants';
import { useSettingsField } from './useSettingsField';
const CREATE_BRAVE = '__create_brave__';
const CREATE_SEARXNG = '__create_searxng__';
const i18n = useI18n();
const uiStore = useUIStore();
const { store } = useSettingsField();
const searchCredentials = computed(() =>
store.serviceCredentials.filter((c) => c.type === 'braveSearchApi' || c.type === 'searXngApi'),
);
const selectedSearchCredentialId = computed(() => {
if (store.draft.searchCredentialId !== undefined) return store.draft.searchCredentialId ?? '';
return store.settings?.searchCredentialId ?? '';
});
let creatingCredential = false;
function handleSearchCredentialChange(value: string | number | boolean | null) {
if (value === CREATE_BRAVE) {
creatingCredential = true;
uiStore.openNewCredential('braveSearchApi');
return;
}
if (value === CREATE_SEARXNG) {
creatingCredential = true;
uiStore.openNewCredential('searXngApi');
return;
}
store.setField('searchCredentialId', value ? String(value) : null);
}
watch(
() => uiStore.isModalActiveById[CREDENTIAL_EDIT_MODAL_KEY],
async (isOpen, wasOpen) => {
if (!wasOpen || isOpen) return;
const previousIds = new Set(searchCredentials.value.map((c) => c.id));
await store.refreshCredentials();
if (creatingCredential) {
creatingCredential = false;
const newCred = searchCredentials.value.find((c) => !previousIds.has(c.id));
if (newCred) {
store.setField('searchCredentialId', newCred.id);
}
}
},
);
</script>
<template>
<div :class="$style.section">
<N8nHeading tag="h2" size="small">
{{ i18n.baseText('instanceAi.settings.section.search') }}
</N8nHeading>
<N8nInputLabel
:label="i18n.baseText('instanceAi.settings.searchCredential.label')"
:bold="false"
size="small"
>
<N8nSelect
:model-value="selectedSearchCredentialId"
size="small"
:placeholder="i18n.baseText('instanceAi.settings.credential.placeholder')"
@update:model-value="handleSearchCredentialChange"
>
<N8nOption value="" :label="i18n.baseText('instanceAi.settings.credential.none')" />
<N8nOption
v-for="cred in searchCredentials"
:key="cred.id"
:value="cred.id"
:label="`${cred.name} (${cred.type === 'braveSearchApi' ? 'Brave' : 'SearXNG'})`"
/>
<N8nOption
:value="CREATE_BRAVE"
:label="i18n.baseText('instanceAi.settings.credential.createBrave')"
/>
<N8nOption
:value="CREATE_SEARXNG"
:label="i18n.baseText('instanceAi.settings.credential.createSearxng')"
/>
</N8nSelect>
</N8nInputLabel>
</div>
</template>
<style lang="scss" module>
.section {
display: flex;
flex-direction: column;
gap: var(--spacing--xs);
}
</style>
@@ -0,0 +1,321 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { fireEvent, waitFor } from '@testing-library/vue';
import { createTestingPinia } from '@pinia/testing';
import { setActivePinia } from 'pinia';
import type { ICredentialType, INodeCredentialTestResult } from 'n8n-workflow';
import { nextTick } from 'vue';
import { createComponentRenderer } from '@/__tests__/render';
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,
}),
});
},
}),
};
});
vi.mock('@n8n/i18n', async (importOriginal) => ({
...(await importOriginal()),
useI18n: () => ({ baseText: (key: string) => key }),
}));
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(),
}));
vi.mock('@/app/utils/rbac/permissions', () => ({
hasPermission: vi.fn().mockReturnValue(true),
}));
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' } },
};
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)', () => {
let store: ReturnType<typeof useInstanceAiSettingsStore>;
beforeEach(() => {
vi.clearAllMocks();
setActivePinia(createTestingPinia({ stubActions: false }));
store = useInstanceAiSettingsStore();
store.$patch({
settings: {
enabled: true,
permissions: {},
mcpAccessEnabled: true,
sandboxEnabled: false,
sandboxProvider: 'daytona',
daytonaCredentialId: null,
n8nSandboxCredentialId: null,
searchCredentialId: null,
modelCredentialId: null,
modelName: null,
modelEnvConfigured: false,
sandboxEnvConfigured: false,
searchEnvConfigured: false,
localGatewayDisabled: false,
},
});
});
it('renders an input per visible property of the selected credential type', async () => {
useCredentialsStore().setCredentialTypes([DAYTONA_TYPE]);
const { findByTestId, getAllByTestId, getByTestId } = renderDialog({
props: { kind: 'sandbox', open: true },
});
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',
);
});
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('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({
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'));
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([[]]));
});
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);
store.$patch({
settings: {
...store.settings!,
sandboxProvider: 'n8n-sandbox',
daytonaCredentialId: 'daytona-id',
n8nSandboxCredentialId: 'n8n-id',
},
});
vi.mocked(store.save).mockResolvedValue(true);
const { findByTestId, getByTestId } = 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' },
},
});
});
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' },
],
});
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));
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());
});
});
@@ -1,64 +0,0 @@
import { useInstanceAiSettingsStore } from '../../instanceAiSettings.store';
import type {
InstanceAiAdminSettingsResponse,
InstanceAiAdminSettingsUpdateRequest,
InstanceAiUserPreferencesResponse,
InstanceAiUserPreferencesUpdateRequest,
} from '@n8n/api-types';
type StringField = keyof {
[K in keyof InstanceAiAdminSettingsResponse as InstanceAiAdminSettingsResponse[K] extends string
? K
: never]: true;
};
type NumberField = keyof {
[K in keyof InstanceAiAdminSettingsResponse as InstanceAiAdminSettingsResponse[K] extends number
? K
: never]: true;
};
type BoolField = keyof {
[K in keyof InstanceAiAdminSettingsResponse as InstanceAiAdminSettingsResponse[K] extends boolean
? K
: never]: true;
};
type PreferenceStringField = keyof {
[K in keyof InstanceAiUserPreferencesResponse as InstanceAiUserPreferencesResponse[K] extends string
? K
: never]: true;
};
export function useSettingsField() {
const store = useInstanceAiSettingsStore();
function getString(key: StringField & keyof InstanceAiAdminSettingsUpdateRequest): string {
const draftVal = store.draft[key];
if (draftVal !== undefined) return String(draftVal);
return store.settings?.[key] ?? '';
}
function getNumber(key: NumberField & keyof InstanceAiAdminSettingsUpdateRequest): number {
const draftVal = store.draft[key];
if (draftVal !== undefined) return Number(draftVal);
return store.settings?.[key] ?? 0;
}
function getBool(key: BoolField & keyof InstanceAiAdminSettingsUpdateRequest): boolean {
const draftVal = store.draft[key];
if (draftVal !== undefined) return Boolean(draftVal);
const fromSettings = store.settings?.[key];
if (fromSettings !== undefined) return Boolean(fromSettings);
if (key === 'enabled') return true;
return false;
}
function getPreferenceString(
key: PreferenceStringField & keyof InstanceAiUserPreferencesUpdateRequest,
): string {
const draftVal = store.preferencesDraft[key];
if (draftVal !== undefined) return String(draftVal);
return store.preferences?.[key] ?? '';
}
return { store, getString, getNumber, getBool, getPreferenceString };
}
@@ -0,0 +1,21 @@
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 };
}
@@ -0,0 +1,112 @@
import { ref } from 'vue';
import { i18n } from '@n8n/i18n';
import {
displayParameter,
type ICredentialDataDecryptedObject,
type ICredentialsDecrypted,
} from 'n8n-workflow';
import { useCredentialsStore } from '@/features/credentials/credentials.store';
import { useCredentialTestInBackground } from '@/features/credentials/composables/useCredentialTestInBackground';
const genericError = () => i18n.baseText('instanceAi.workflowSetup.credentialTestFailedTooltip');
export function useInstanceCredentialTest() {
const credentialsStore = useCredentialsStore();
const { isCredentialTypeTestable } = useCredentialTestInBackground();
const credentialTestError = ref('');
const isTestingCredential = ref(false);
function hasRequiredData(credentials: ICredentialsDecrypted): boolean {
const data = credentials.data ?? {};
return (credentialsStore.getCredentialTypeByName(credentials.type)?.properties ?? []).every(
(property) => {
if (!property.required || !displayParameter(data, property, null, null)) return true;
const value = data[property.name];
const hasValue =
typeof value === 'string'
? value.trim().length > 0
: value !== null && value !== undefined;
return hasValue || (property.default !== undefined && property.default !== '');
},
);
}
async function runCredentialTest(credentials: ICredentialsDecrypted): Promise<boolean> {
const result = await credentialsStore.testCredential(credentials);
if (result.status === 'OK') return true;
credentialTestError.value = result.message || genericError();
return false;
}
async function testCredential(credentials: ICredentialsDecrypted): Promise<boolean> {
credentialTestError.value = '';
if (!isCredentialTypeTestable(credentials.type)) {
const isValid = hasRequiredData(credentials);
if (!isValid) credentialTestError.value = genericError();
return isValid;
}
isTestingCredential.value = true;
try {
return await runCredentialTest(credentials);
} catch {
credentialTestError.value = genericError();
return false;
} finally {
isTestingCredential.value = false;
}
}
// Results land in credentialsStore.credentialTestResults (keyed by credential id) so any
// consumer — view pre-flight or a dialog — can resurrect the failure via restoreStoredError.
async function testSavedCredential(id: string, name: string, type: string): Promise<boolean> {
if (!isCredentialTypeTestable(type)) return true;
credentialTestError.value = '';
isTestingCredential.value = true;
try {
const credential = await credentialsStore.getCredentialData({ id });
const credentialData = credential && 'data' in credential ? credential.data : undefined;
if (!credentialData || typeof credentialData === 'string') throw new Error();
const {
ownedBy: _ownedBy,
sharedWithProjects: _sharedWithProjects,
oauthTokenData,
...data
} = credentialData;
if (oauthTokenData) {
credentialsStore.credentialTestResults.set(id, 'success');
return true;
}
return await runCredentialTest({
id,
name,
type,
data: data as ICredentialDataDecryptedObject,
});
} catch {
credentialsStore.credentialTestResults.set(id, 'error');
credentialTestError.value = genericError();
return false;
} finally {
isTestingCredential.value = false;
}
}
/** Resurfaces a failure recorded for this credential by any prior testSavedCredential run. */
function restoreStoredError(id: string | null | undefined) {
credentialTestError.value =
id && credentialsStore.credentialTestResults.get(id) === 'error' ? genericError() : '';
}
return {
credentialTestError,
isTestingCredential,
testCredential,
testSavedCredential,
restoreStoredError,
};
}
@@ -3,10 +3,17 @@ import { INSTANCE_AI_THREAD_SOURCES, type InstanceAiThreadSource } from '@n8n/ap
export const INSTANCE_AI_VIEW = 'InstanceAi';
export const INSTANCE_AI_THREAD_VIEW = 'InstanceAiThread';
export const INSTANCE_AI_SETTINGS_VIEW = 'InstanceAiSettings';
export const INSTANCE_AI_CREDENTIALS_SETTINGS_VIEW = 'InstanceAiCredentialsSettings';
export const INSTANCE_AI_PROJECT_ID_QUERY = 'projectId';
/** Entry-point source carried into the empty view when a hand-off can't create a thread yet. */
export const INSTANCE_AI_SOURCE_QUERY = 'source';
/** Brand names, deliberately not translated; single source for dialogs and settings rows. */
export const SANDBOX_PROVIDER_LABELS = {
daytona: 'Daytona',
'n8n-sandbox': 'n8n Sandbox Service',
} as const;
export type InstanceAiConnectionKind = 'model' | 'sandbox' | 'search';
export const INSTANCE_AI_NEW_VIEW = 'InstanceAiNew';
export const INSTANCE_AI_AGENT_BUILDER_TARGET_METADATA_KEY = 'instanceAiAgentBuilderTarget';
export const NEW_CONVERSATION_TITLE = 'New conversation';
@@ -5,7 +5,7 @@ import type {
InstanceAiAdminSettingsUpdateRequest,
InstanceAiUserPreferencesResponse,
InstanceAiUserPreferencesUpdateRequest,
InstanceAiModelCredential,
InstanceAiProviderConnection,
} from '@n8n/api-types';
export async function fetchSettings(
@@ -34,20 +34,14 @@ export async function updatePreferences(
return await makeRestApiRequest(context, 'PUT', '/instance-ai/preferences', body);
}
export async function fetchModelCredentials(
context: IRestApiContext,
): Promise<InstanceAiModelCredential[]> {
return await makeRestApiRequest(context, 'GET', '/instance-ai/settings/credentials');
}
export async function fetchServiceCredentials(
context: IRestApiContext,
): Promise<InstanceAiModelCredential[]> {
): Promise<InstanceAiProviderConnection[]> {
return await makeRestApiRequest(context, 'GET', '/instance-ai/settings/service-credentials');
}
export async function fetchInstanceModelCredentials(
context: IRestApiContext,
): Promise<InstanceAiModelCredential[]> {
): Promise<InstanceAiProviderConnection[]> {
return await makeRestApiRequest(context, 'GET', '/instance-ai/settings/model-credentials');
}
@@ -9,7 +9,6 @@ import {
updateSettings,
fetchPreferences,
updatePreferences,
fetchModelCredentials,
fetchServiceCredentials,
fetchInstanceModelCredentials,
} from './instanceAi.settings.api';
@@ -27,8 +26,7 @@ import type {
InstanceAiAdminSettingsResponse,
InstanceAiAdminSettingsUpdateRequest,
InstanceAiUserPreferencesResponse,
InstanceAiUserPreferencesUpdateRequest,
InstanceAiModelCredential,
InstanceAiProviderConnection,
InstanceAiPermissions,
InstanceAiPermissionMode,
ToolCategory,
@@ -50,11 +48,9 @@ export const useInstanceAiSettingsStore = defineStore('instanceAiSettings', () =
const isSaving = ref(false);
const settings = ref<InstanceAiAdminSettingsResponse | null>(null);
const preferences = ref<InstanceAiUserPreferencesResponse | null>(null);
const credentials = ref<InstanceAiModelCredential[]>([]);
const serviceCredentials = ref<InstanceAiModelCredential[]>([]);
const instanceModelCredentials = ref<InstanceAiModelCredential[]>([]);
const serviceCredentials = ref<InstanceAiProviderConnection[]>([]);
const instanceModelCredentials = ref<InstanceAiProviderConnection[]>([]);
const draft = reactive<InstanceAiAdminSettingsUpdateRequest>({});
const preferencesDraft = reactive<InstanceAiUserPreferencesUpdateRequest>({});
// ── Gateway / daemon state ──────────────────────────────────────────
const HAS_CONNECTED_STORAGE_KEY = 'instanceAi.gateway.hasConnected';
@@ -99,7 +95,6 @@ export const useInstanceAiSettingsStore = defineStore('instanceAiSettings', () =
const browserConnectUrl = ref<string | null>(null);
const browserConnectUrlExpiresAt = ref<string | null>(null);
let browserConnectUrlRequestId = 0;
const activeDirectory = computed(() => gatewayDirectory.value);
const isInstanceAiDisabled = computed(
() => settingsStore.moduleSettings?.['instance-ai']?.enabled !== true,
);
@@ -125,14 +120,6 @@ export const useInstanceAiSettingsStore = defineStore('instanceAiSettings', () =
const isWorkflowBuilderAvailable = computed(
() => settingsStore.moduleSettings?.['instance-ai']?.workflowBuilderAvailable ?? true,
);
const sandboxUnavailableReason = computed(
() => settingsStore.moduleSettings?.['instance-ai']?.sandboxUnavailableReason ?? null,
);
const isDirty = computed(() => {
if (!settings.value && !preferences.value) return false;
return Object.keys(draft).length > 0 || Object.keys(preferencesDraft).length > 0;
});
function syncInstanceAiFlagIntoGlobalModuleSettings(
adminRes: InstanceAiAdminSettingsResponse,
@@ -162,6 +149,12 @@ export const useInstanceAiSettingsStore = defineStore('instanceAiSettings', () =
const canManage = computed(() =>
hasPermission(['rbac'], { rbac: { scope: 'instanceAi:manage' } }),
);
const canManageAiUsage = computed(() =>
hasPermission(['rbac'], { rbac: { scope: 'aiAssistant:manage' } }),
);
const canManageInstanceCredentials = computed(() =>
hasPermission(['rbac'], { rbac: { scope: 'credential:manageInstance' } }),
);
async function fetch(): Promise<void> {
isLoading.value = true;
@@ -176,87 +169,74 @@ export const useInstanceAiSettingsStore = defineStore('instanceAiSettings', () =
const [s, p] = await Promise.all(promises);
settings.value = s;
preferences.value = p;
if (!isProxyEnabled.value && !isCloudManaged.value) {
const credPromises: [
Promise<InstanceAiModelCredential[]>,
Promise<InstanceAiModelCredential[]>,
Promise<InstanceAiModelCredential[]>,
] = [
fetchModelCredentials(rootStore.restApiContext),
canManage.value ? fetchServiceCredentials(rootStore.restApiContext) : Promise.resolve([]),
canManage.value
? fetchInstanceModelCredentials(rootStore.restApiContext)
: Promise.resolve([]),
];
const [c, sc, imc] = await Promise.all(credPromises);
credentials.value = c;
if (!isProxyEnabled.value && !isCloudManaged.value && canManage.value) {
const [sc, imc] = await Promise.all([
fetchServiceCredentials(rootStore.restApiContext),
fetchInstanceModelCredentials(rootStore.restApiContext),
]);
serviceCredentials.value = sc;
instanceModelCredentials.value = imc;
}
clearDraft();
} catch {
toast.showError(new Error('Failed to load settings'), 'Settings error');
toast.showError(
new Error(i18n.baseText('settings.n8nAgent.toast.loadError')),
i18n.baseText('settings.n8nAgent.toast.errorTitle'),
);
} finally {
isLoading.value = false;
}
}
async function save(): Promise<void> {
/**
* 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> {
if (Object.keys(draft).length === 0) return true;
isSaving.value = true;
try {
const hasAdminChanges = Object.keys(draft).length > 0;
const hasPreferenceChanges = Object.keys(preferencesDraft).length > 0;
const [adminResult, prefsResult] = await Promise.allSettled([
hasAdminChanges
? updateSettings(rootStore.restApiContext, {
...toRaw(draft),
} as InstanceAiAdminSettingsUpdateRequest)
: Promise.resolve(settings.value),
hasPreferenceChanges
? updatePreferences(rootStore.restApiContext, preferencesDraft)
: Promise.resolve(preferences.value),
]);
if (adminResult.status === 'fulfilled' && adminResult.value)
settings.value = adminResult.value;
if (prefsResult.status === 'fulfilled' && prefsResult.value)
preferences.value = prefsResult.value;
const failed = [adminResult, prefsResult].filter((r) => r.status === 'rejected');
if (failed.length > 0) {
throw (failed[0] as PromiseRejectedResult).reason;
}
const result = await updateSettings(rootStore.restApiContext, {
...toRaw(draft),
} as InstanceAiAdminSettingsUpdateRequest);
settings.value = result;
clearDraft();
toast.showMessage({ title: 'Settings saved', type: 'success' });
if (hasAdminChanges) {
await settingsStore.getModuleSettings();
const adminSaved =
adminResult.status === 'fulfilled' && adminResult.value ? adminResult.value : null;
if (adminSaved) {
syncInstanceAiFlagIntoGlobalModuleSettings(adminSaved);
}
}
} catch {
toast.showError(new Error('Failed to save settings'), 'Settings error');
toast.showMessage({
title: i18n.baseText('settings.n8nAgent.toast.saved'),
type: 'success',
});
syncInstanceAiFlagIntoGlobalModuleSettings(result);
await settingsStore.getModuleSettings().catch(() => {});
return true;
} catch (error) {
clearDraft();
toast.showError(error, i18n.baseText('settings.n8nAgent.toast.errorTitle'));
return false;
} finally {
isSaving.value = false;
}
}
/** Persists only the Instance AI on/off flag (does not send other admin draft fields). */
async function persistEnabled(value: boolean): Promise<void> {
async function persistEnabled(value: boolean): Promise<boolean> {
isSaving.value = true;
try {
const result = await updateSettings(rootStore.restApiContext, { enabled: value });
settings.value = result;
delete draft.enabled;
await settingsStore.getModuleSettings();
syncInstanceAiFlagIntoGlobalModuleSettings(result);
toast.showMessage({ title: 'Settings saved', type: 'success' });
await settingsStore.getModuleSettings().catch(() => {});
toast.showMessage({
title: i18n.baseText('settings.n8nAgent.toast.saved'),
type: 'success',
});
return true;
} catch {
toast.showError(new Error('Failed to save settings'), 'Settings error');
toast.showError(
new Error(i18n.baseText('settings.n8nAgent.toast.saveError')),
i18n.baseText('settings.n8nAgent.toast.errorTitle'),
);
return false;
} finally {
isSaving.value = false;
}
@@ -269,7 +249,10 @@ export const useInstanceAiSettingsStore = defineStore('instanceAiSettings', () =
});
preferences.value = result;
} catch {
toast.showError(new Error('Failed to save preference'), 'Settings error');
toast.showError(
new Error(i18n.baseText('settings.n8nAgent.toast.preferenceError')),
i18n.baseText('settings.n8nAgent.toast.errorTitle'),
);
}
}
@@ -363,23 +346,14 @@ export const useInstanceAiSettingsStore = defineStore('instanceAiSettings', () =
key: K,
value: InstanceAiAdminSettingsUpdateRequest[K],
): void {
draft[key] = value;
}
function setPreferenceField<K extends keyof InstanceAiUserPreferencesUpdateRequest>(
key: K,
value: InstanceAiUserPreferencesUpdateRequest[K],
): void {
preferencesDraft[key] = value;
if (value === undefined) delete draft[key];
else draft[key] = value;
}
function clearDraft(): void {
for (const key of Object.keys(draft)) {
delete (draft as Record<string, unknown>)[key];
}
for (const key of Object.keys(preferencesDraft)) {
delete (preferencesDraft as Record<string, unknown>)[key];
}
}
function setPermission(key: keyof InstanceAiPermissions, value: InstanceAiPermissionMode): void {
@@ -393,10 +367,6 @@ export const useInstanceAiSettingsStore = defineStore('instanceAiSettings', () =
return settings.value?.permissions?.[key] ?? 'require_approval';
}
function reset(): void {
clearDraft();
}
// ── Gateway status fetch ──────────────────────────────────────────────
async function fetchGatewayStatus(): Promise<void> {
@@ -599,12 +569,7 @@ export const useInstanceAiSettingsStore = defineStore('instanceAiSettings', () =
async function refreshCredentials(): Promise<void> {
if (isProxyEnabled.value) return;
try {
const [c, sc] = await Promise.all([
fetchModelCredentials(rootStore.restApiContext),
fetchServiceCredentials(rootStore.restApiContext),
]);
credentials.value = c;
serviceCredentials.value = sc;
serviceCredentials.value = await fetchServiceCredentials(rootStore.restApiContext);
} catch {
// Silently fail — credentials list will refresh on next full fetch
}
@@ -633,26 +598,23 @@ export const useInstanceAiSettingsStore = defineStore('instanceAiSettings', () =
return {
canManage,
canManageAiUsage,
canManageInstanceCredentials,
settings,
preferences,
credentials,
serviceCredentials,
instanceModelCredentials,
draft,
preferencesDraft,
isLoading,
isSaving,
isDirty,
fetch,
save,
persistEnabled,
persistLocalGatewayPreference,
ensurePreferencesLoaded,
setField,
setPreferenceField,
setPermission,
getPermission,
reset,
// Gateway / daemon
isDaemonConnecting,
setupCommand,
@@ -665,7 +627,6 @@ export const useInstanceAiSettingsStore = defineStore('instanceAiSettings', () =
gatewayDirectory,
gatewayHostIdentifier,
gatewayToolCategories,
activeDirectory,
isInstanceAiDisabled,
isLocalGatewayDisabled,
isLocalGatewayDisabledByAdmin,
@@ -673,7 +634,6 @@ export const useInstanceAiSettingsStore = defineStore('instanceAiSettings', () =
isProxyEnabled,
isSandboxEnabled,
isWorkflowBuilderAvailable,
sandboxUnavailableReason,
fetchGatewayStatus,
connectLocalGateway,
isCloudManaged,
@@ -9,7 +9,6 @@ import {
INSTANCE_AI_VIEW,
INSTANCE_AI_THREAD_VIEW,
INSTANCE_AI_SETTINGS_VIEW,
INSTANCE_AI_CREDENTIALS_SETTINGS_VIEW,
INSTANCE_AI_NEW_VIEW,
} from './constants';
import {
@@ -23,8 +22,6 @@ const InstanceAiView = async () => await import('./InstanceAiView.vue');
const InstanceAiEmptyView = async () => await import('./InstanceAiEmptyView.vue');
const InstanceAiThreadView = async () => await import('./InstanceAiThreadView.vue');
const SettingsInstanceAiView = async () => await import('./views/SettingsInstanceAiView.vue');
const SettingsInstanceAiCredentialsView = async () =>
await import('./views/SettingsInstanceAiCredentialsView.vue');
const ComputerUseSetupModal = async () =>
await import('./components/modals/ComputerUseSetupModal.vue');
const BrowserUseSetupModal = async () =>
@@ -132,27 +129,19 @@ export const InstanceAiModule: FrontendModuleDescription = {
},
},
},
// Permanent redirect from the legacy `/settings/instance-ai` path.
{
path: 'assistant/credentials',
name: INSTANCE_AI_CREDENTIALS_SETTINGS_VIEW,
component: SettingsInstanceAiCredentialsView,
path: 'instance-ai',
redirect: (to) => ({ name: INSTANCE_AI_SETTINGS_VIEW, query: to.query, hash: to.hash }),
meta: {
layout: 'settings',
middleware: ['authenticated', 'rbac', 'custom'],
middlewareOptions: {
rbac: {
scope: ['instanceAi:manage', 'credential:manageInstance'],
options: { mode: 'allOf' },
},
},
telemetry: {
pageCategory: 'settings',
},
},
},
// Permanent redirect from the legacy `/settings/instance-ai` path.
// Permanent redirect from the removed `/settings/assistant/credentials` page.
{
path: 'instance-ai',
path: 'assistant/credentials',
redirect: (to) => ({ name: INSTANCE_AI_SETTINGS_VIEW, query: to.query, hash: to.hash }),
meta: {
telemetry: {
@@ -1,214 +0,0 @@
<script lang="ts" setup>
import { computed, onMounted, watch } from 'vue';
import {
N8nButton,
N8nDropdownMenu,
N8nEmptyState,
N8nIcon,
N8nLoading,
N8nOption,
N8nSelect,
N8nSettingsLayout,
N8nSettingsPageHeader,
N8nSettingsRow,
N8nSettingsRowGroup,
N8nSettingsSection,
type DropdownMenuItemProps,
} from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { useRouter } from 'vue-router';
import { useDocumentTitle } from '@/app/composables/useDocumentTitle';
import { useUIStore } from '@/app/stores/ui.store';
import { CREDENTIAL_EDIT_MODAL_KEY } from '@/features/credentials/credentials.constants';
import { useCredentialsStore } from '@/features/credentials/credentials.store';
import { INSTANCE_AI_SETTINGS_VIEW } from '../constants';
import { useInstanceAiSettingsStore } from '../instanceAiSettings.store';
const i18n = useI18n();
const documentTitle = useDocumentTitle();
const router = useRouter();
const uiStore = useUIStore();
const credentialsStore = useCredentialsStore();
const store = useInstanceAiSettingsStore();
const INSTANCE_MODEL_CREDENTIAL_TYPES = [
'openAiApi',
'anthropicApi',
'googlePalmApi',
'ollamaApi',
'groqApi',
'deepSeekApi',
'mistralCloudApi',
'xAiApi',
'openRouterApi',
'cohereApi',
] as const;
function credentialTypeLabel(type: string) {
return credentialsStore.getCredentialTypeByName(type)?.displayName ?? type;
}
const createModelCredentialItems = computed<Array<DropdownMenuItemProps<string>>>(() =>
INSTANCE_MODEL_CREDENTIAL_TYPES.map((type) => ({ id: type, label: credentialTypeLabel(type) })),
);
const canConfigure = computed(
() => store.canManage && !store.isProxyEnabled && !store.isCloudManaged,
);
const selectedModelCredentialId = computed(() => {
if (store.draft.modelCredentialId !== undefined) return store.draft.modelCredentialId ?? '';
return store.settings?.modelCredentialId ?? '';
});
let creatingModelCredential = false;
onMounted(() => {
documentTitle.set(i18n.baseText('settings.n8nAgent.credentials.title'));
void store.fetch();
});
function goBack() {
void router.push({ name: INSTANCE_AI_SETTINGS_VIEW });
}
function handleModelCredentialChange(value: string | number | boolean | null) {
store.setField('modelCredentialId', value ? String(value) : null);
void store.save();
}
function handleCreateModelCredential(credentialType: string) {
creatingModelCredential = true;
uiStore.openNewCredential(
credentialType,
false,
false,
undefined,
undefined,
undefined,
undefined,
{ usageScope: 'instance', closeOnSave: true },
);
}
function editModelCredential() {
if (selectedModelCredentialId.value) {
uiStore.openExistingCredential(selectedModelCredentialId.value, { hideAskAssistant: true });
}
}
watch(
() => uiStore.isModalActiveById[CREDENTIAL_EDIT_MODAL_KEY],
async (isOpen, wasOpen) => {
if (!wasOpen || isOpen || !canConfigure.value) return;
const previousIds = new Set(store.instanceModelCredentials.map((credential) => credential.id));
await store.refreshInstanceModelCredentials();
if (!creatingModelCredential) return;
creatingModelCredential = false;
const newCredential = store.instanceModelCredentials.find(
(credential) => !previousIds.has(credential.id),
);
if (newCredential) handleModelCredentialChange(newCredential.id);
},
);
</script>
<template>
<N8nSettingsLayout
show-back
:back-label="i18n.baseText('settings.n8nAgent.credentials.back')"
data-test-id="n8n-agent-credentials-settings"
@back="goBack"
>
<N8nSettingsPageHeader
:title="i18n.baseText('settings.n8nAgent.credentials.title')"
:description="i18n.baseText('settings.n8nAgent.credentials.pageDescription')"
:show-docs-link="false"
/>
<N8nLoading v-if="store.isLoading" :rows="3" :shrink-last="false" />
<N8nSettingsSection
v-else-if="canConfigure"
:title="i18n.baseText('settings.n8nAgent.credentials.model.title')"
:description="i18n.baseText('settings.n8nAgent.credentials.model.description')"
>
<N8nSettingsRowGroup>
<N8nSettingsRow
:title="i18n.baseText('settings.n8nAgent.modelCredential.label')"
:description="i18n.baseText('settings.n8nAgent.modelCredential.description')"
:action-max-width="false"
>
<template #action>
<div :class="$style.credentialControls">
<N8nSelect
:class="$style.credentialSelect"
:model-value="selectedModelCredentialId"
size="medium"
:disabled="store.isSaving"
:placeholder="i18n.baseText('settings.n8nAgent.modelCredential.placeholder')"
data-test-id="n8n-agent-model-credential-select"
@update:model-value="handleModelCredentialChange"
>
<N8nOption
value=""
:label="i18n.baseText('settings.n8nAgent.modelCredential.none')"
/>
<N8nOption
v-for="credential in store.instanceModelCredentials"
:key="credential.id"
:value="credential.id"
:label="`${credential.name} · ${credentialTypeLabel(credential.type)}`"
/>
</N8nSelect>
<N8nButton
v-if="selectedModelCredentialId"
variant="outline"
size="medium"
:label="i18n.baseText('settings.n8nAgent.credentials.edit')"
:disabled="store.isSaving"
data-test-id="n8n-agent-model-credential-edit"
@click="editModelCredential"
/>
<N8nDropdownMenu
:items="createModelCredentialItems"
placement="bottom-end"
data-test-id="n8n-agent-model-credential-create"
@select="handleCreateModelCredential"
>
<template #trigger>
<N8nButton variant="outline" size="medium" :disabled="store.isSaving">
{{ i18n.baseText('settings.n8nAgent.modelCredential.createNew') }}
<N8nIcon icon="chevron-down" size="small" />
</N8nButton>
</template>
</N8nDropdownMenu>
</div>
</template>
</N8nSettingsRow>
</N8nSettingsRowGroup>
</N8nSettingsSection>
<N8nEmptyState
v-else
:icon="{ type: 'icon', value: 'key-round' }"
:heading="i18n.baseText('settings.n8nAgent.credentials.unavailable.title')"
:description="i18n.baseText('settings.n8nAgent.credentials.unavailable.description')"
/>
</N8nSettingsLayout>
</template>
<style lang="scss" module>
.credentialControls {
display: flex;
align-items: center;
justify-content: flex-end;
gap: var(--spacing--2xs);
flex-wrap: wrap;
}
.credentialSelect {
width: 15rem;
}
</style>
@@ -1,12 +1,14 @@
<script lang="ts" setup>
import { computed, onMounted } from 'vue';
import { computed, onMounted, reactive, ref, watch } from 'vue';
import {
N8nBadge,
N8nButton,
N8nDropdownMenu,
N8nEmptyState,
N8nIcon,
N8nLoading,
N8nOption,
N8nPreviewTag,
N8nSelect,
N8nSettingsLayout,
N8nSettingsPageHeader,
@@ -15,48 +17,127 @@ import {
N8nSettingsRowGroup,
N8nSettingsSection,
N8nSwitch,
N8nText,
type DropdownMenuItemProps,
type EmptyStateIconCards,
} from '@n8n/design-system';
import type { InstanceAiPermissions, InstanceAiPermissionMode } from '@n8n/api-types';
import { type BaseTextKey, useI18n } from '@n8n/i18n';
import { useRouter } from 'vue-router';
import { MODAL_CONFIRM } from '@/app/constants';
import { MODAL_CONFIRM, VIEWS } from '@/app/constants';
import { useDocumentTitle } from '@/app/composables/useDocumentTitle';
import { useMessage } from '@/app/composables/useMessage';
import { useSettingsStore } from '@/app/stores/settings.store';
import { useCredentialsStore } from '@/features/credentials/credentials.store';
import { useInstanceAiBrowserUseExperiment } from '@/experiments/instanceAiBrowserUse';
import { useInstanceAiComputerUseExperiment } from '@/experiments/instanceAiComputerUse';
import { useInstanceAiMcpConnectionsExperiment } from '@/experiments/instanceAiMcpConnections';
import { INSTANCE_AI_CREDENTIALS_SETTINGS_VIEW } from '../constants';
import { useInstanceCredentialTest } from '../composables/useInstanceCredentialTest';
import { useInstanceAiSettingsStore } from '../instanceAiSettings.store';
import { SANDBOX_PROVIDER_LABELS, type InstanceAiConnectionKind } from '../constants';
import ConnectionDialog from '../components/settings/ConnectionDialog.vue';
const i18n = useI18n();
const documentTitle = useDocumentTitle();
const message = useMessage();
const router = useRouter();
const settingsStore = useSettingsStore();
const credentialsStore = useCredentialsStore();
const store = useInstanceAiSettingsStore();
const { isTestingCredential, testSavedCredential } = useInstanceCredentialTest();
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 isAdmin = computed(() => store.canManage);
const isEnabled = computed(
() => store.settings?.enabled ?? settingsStore.moduleSettings?.['instance-ai']?.enabled ?? false,
);
const isOff = computed(() => !isEnabled.value);
const isMcpAccessEnabled = computed(() => store.settings?.mcpAccessEnabled ?? true);
const showCredentialsRow = computed(
() => isAdmin.value && !store.isProxyEnabled && !store.isCloudManaged,
const isSelfManaged = computed(() => !store.isProxyEnabled && !store.isCloudManaged);
const showCredentialsRows = computed(() => isAdmin.value && isSelfManaged.value);
const modelCredential = computed(() =>
store.instanceModelCredentials.find(
(credential) => credential.id === store.settings?.modelCredentialId,
),
);
const showCapabilitiesSection = computed(
const isModelConfigured = computed(() =>
Boolean(
store.settings?.modelEnvConfigured ||
(store.settings?.modelCredentialId && store.settings.modelName),
),
);
const modelValue = computed(() => {
if (store.settings?.modelCredentialId) {
const typeLabel = modelCredential.value ? credentialTypeLabel(modelCredential.value.type) : '';
const modelName = store.settings.modelName ?? '';
return [typeLabel, modelName].filter(Boolean).join(' · ');
}
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)
return { key: 'settings.n8nAgent.modelCredential.env.description', warning: false };
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 (sandboxCredentialId.value) {
return store.settings?.sandboxProvider === 'daytona'
? SANDBOX_PROVIDER_LABELS.daytona
: SANDBOX_PROVIDER_LABELS['n8n-sandbox'];
}
return i18n.baseText('settings.n8nAgent.sandbox.env.value');
});
const sandboxDescription = computed<{ key: BaseTextKey; warning: boolean }>(() => {
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');
return searchCredential.value ? credentialTypeLabel(searchCredential.value.type) : '';
});
const isSetupRequired = computed(
() =>
isComputerUseExperimentEnabled.value ||
isBrowserUseEnabled.value ||
isMcpConnectionsExperimentEnabled.value,
isEnabled.value &&
showCredentialsRows.value &&
(!isModelConfigured.value || !isSandboxConfigured.value),
);
const neverConfigured = computed(() => {
if (isEnabled.value) return false;
if (!isSelfManaged.value || !store.settings) return true;
return !isModelConfigured.value && !isSandboxConfigured.value && searchState.value === 'notset';
});
const emptyStateIcon: EmptyStateIconCards = {
type: 'cards',
@@ -89,43 +170,204 @@ const PERMISSION_OPTION_LABEL: Record<InstanceAiPermissionMode, BaseTextKey> = {
blocked: 'settings.n8nAgent.permissions.blocked',
};
const permissionKeys: Array<{
key: keyof InstanceAiPermissions;
interface PermissionGroup {
id: string;
labelKey: BaseTextKey;
}> = [
{ key: 'createWorkflow', labelKey: 'settings.n8nAgent.permissions.createWorkflow' },
{ key: 'updateWorkflow', labelKey: 'settings.n8nAgent.permissions.updateWorkflow' },
{ key: 'runWorkflow', labelKey: 'settings.n8nAgent.permissions.runWorkflow' },
{ key: 'publishWorkflow', labelKey: 'settings.n8nAgent.permissions.publishWorkflow' },
{ key: 'deleteWorkflow', labelKey: 'settings.n8nAgent.permissions.deleteWorkflow' },
{ key: 'deleteCredential', labelKey: 'settings.n8nAgent.permissions.deleteCredential' },
{ key: 'createFolder', labelKey: 'settings.n8nAgent.permissions.createFolder' },
{ key: 'deleteFolder', labelKey: 'settings.n8nAgent.permissions.deleteFolder' },
{ key: 'moveWorkflowToFolder', labelKey: 'settings.n8nAgent.permissions.moveWorkflowToFolder' },
{ key: 'tagWorkflow', labelKey: 'settings.n8nAgent.permissions.tagWorkflow' },
{ key: 'createDataTable', labelKey: 'settings.n8nAgent.permissions.createDataTable' },
{ key: 'mutateDataTableSchema', labelKey: 'settings.n8nAgent.permissions.mutateDataTableSchema' },
{ key: 'mutateDataTableRows', labelKey: 'settings.n8nAgent.permissions.mutateDataTableRows' },
keys: Array<keyof InstanceAiPermissions>;
}
const PERMISSION_GROUPS: PermissionGroup[] = [
{
key: 'cleanupTestExecutions',
labelKey: 'settings.n8nAgent.permissions.cleanupTestExecutions',
id: 'workflows',
labelKey: 'settings.n8nAgent.permissions.group.workflows',
keys: [
'createWorkflow',
'updateWorkflow',
'runWorkflow',
'publishWorkflow',
'deleteWorkflow',
'restoreWorkflowVersion',
'tagWorkflow',
'moveWorkflowToFolder',
],
},
{ key: 'readFilesystem', labelKey: 'settings.n8nAgent.permissions.readFilesystem' },
{ key: 'fetchUrl', labelKey: 'settings.n8nAgent.permissions.fetchUrl' },
{ key: 'webSearch', labelKey: 'settings.n8nAgent.permissions.webSearch' },
{
key: 'restoreWorkflowVersion',
labelKey: 'settings.n8nAgent.permissions.restoreWorkflowVersion',
id: 'folders',
labelKey: 'settings.n8nAgent.permissions.group.folders',
keys: ['createFolder', 'deleteFolder'],
},
{
id: 'dataTables',
labelKey: 'settings.n8nAgent.permissions.group.dataTables',
keys: ['createDataTable', 'mutateDataTableSchema', 'mutateDataTableRows'],
},
{
id: 'credentials',
labelKey: 'settings.n8nAgent.permissions.group.credentials',
keys: ['deleteCredential'],
},
{
id: 'system',
labelKey: 'settings.n8nAgent.permissions.group.system',
keys: ['readFilesystem', 'cleanupTestExecutions'],
},
{
id: 'web',
labelKey: 'settings.n8nAgent.permissions.group.web',
keys: ['fetchUrl', 'webSearch'],
},
];
const MCP_PERMISSION_GROUP: PermissionGroup = {
id: 'mcp',
labelKey: 'settings.n8nAgent.permissions.group.mcp',
keys: ['executeMcpTool'],
};
const permissionGroups = computed(() =>
isMcpConnectionsExperimentEnabled.value
? [...PERMISSION_GROUPS, MCP_PERMISSION_GROUP]
: PERMISSION_GROUPS,
);
const expandedGroups = reactive<Record<string, boolean>>({});
function isGroupLocked(group: PermissionGroup) {
return isOff.value || (group.id === 'mcp' && !isMcpAccessEnabled.value);
}
function groupSummary(group: PermissionGroup) {
if (group.id === 'mcp' && !isMcpAccessEnabled.value)
return i18n.baseText('settings.n8nAgent.permissions.group.mcpDisabled');
const exceptions = group.keys.filter(
(key) => store.getPermission(key) !== 'require_approval',
).length;
if (exceptions === 0) return i18n.baseText('settings.n8nAgent.permissions.group.default');
if (exceptions === 1) return i18n.baseText('settings.n8nAgent.permissions.group.exception');
return i18n.baseText('settings.n8nAgent.permissions.group.exceptions', {
interpolate: { count: exceptions },
});
}
function permissionOptionsFor(key: keyof InstanceAiPermissions) {
return key === 'executeMcpTool' ? MCP_TOOL_PERMISSION_OPTIONS : PERMISSION_OPTIONS;
}
/** Exactly one dialog can be active; transitions between steps never observe an all-closed state. */
const activeDialog = ref<InstanceAiConnectionKind | null>(null);
const setupChain = ref(false);
const enableAfterSetup = ref(false);
watch(activeDialog, (dialog) => {
if (dialog !== null) return;
setupChain.value = false;
enableAfterSetup.value = false;
});
function setDialogOpen(kind: InstanceAiConnectionKind, isOpen: boolean) {
if (isOpen) activeDialog.value = kind;
else if (activeDialog.value === kind) activeDialog.value = null;
}
function openModelDialog() {
setupChain.value = false;
activeDialog.value = 'model';
}
function openModelSetup() {
setupChain.value = !isSandboxConfigured.value;
activeDialog.value = 'model';
}
function openSandboxDialog() {
setupChain.value = false;
activeDialog.value = 'sandbox';
}
/** Returns whether the chain may continue (false only when enabling failed). */
async function finishSetup(): Promise<boolean> {
setupChain.value = false;
if (!enableAfterSetup.value) return true;
enableAfterSetup.value = false;
return await store.persistEnabled(true);
}
async function handleModelSaved() {
if (setupChain.value) {
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';
}
}
function credentialTypeLabel(type: string) {
return credentialsStore.getCredentialTypeByName(type)?.displayName ?? type;
}
onMounted(() => {
documentTitle.set(i18n.baseText('settings.n8nAgent'));
void store.fetch();
void credentialsStore.fetchCredentialTypes(false);
});
async function handleEnable() {
await store.persistEnabled(true);
if (!showCredentialsRows.value) {
await store.persistEnabled(true);
return;
}
enableAfterSetup.value = true;
if (!isModelConfigured.value) {
openModelSetup();
return;
}
const modelCredentialId = store.settings?.modelCredentialId;
if (
modelCredentialId &&
store.canManageInstanceCredentials &&
(!modelCredential.value ||
!(await testSavedCredential(
modelCredentialId,
modelCredential.value.name,
modelCredential.value.type,
)))
) {
openModelSetup();
return;
}
if (!isSandboxConfigured.value) {
openSandboxDialog();
return;
}
if (sandboxCredentialId.value && store.canManageInstanceCredentials) {
const isDaytona = store.settings?.sandboxProvider === 'daytona';
if (
!(await testSavedCredential(
sandboxCredentialId.value,
'AI Assistant sandbox',
isDaytona ? 'daytonaApi' : 'httpHeaderAuth',
))
) {
openSandboxDialog();
return;
}
}
await finishSetup();
}
async function handleStatusAction(action: string) {
@@ -142,10 +384,6 @@ async function handleStatusAction(action: string) {
if (confirmed === MODAL_CONFIRM) await store.persistEnabled(false);
}
function openCredentialsSettings() {
void router.push({ name: INSTANCE_AI_CREDENTIALS_SETTINGS_VIEW });
}
function handleComputerUseToggle(value: boolean) {
store.setField('localGatewayDisabled', !value);
void store.save();
@@ -165,6 +403,10 @@ function handlePermissionChange(key: keyof InstanceAiPermissions, value: Instanc
store.setPermission(key, value);
void store.save();
}
function openAiUsageSettings() {
void router.push({ name: VIEWS.AI_SETTINGS });
}
</script>
<template>
@@ -172,30 +414,52 @@ function handlePermissionChange(key: keyof InstanceAiPermissions, value: Instanc
<N8nSettingsPageHeader
:title="i18n.baseText('settings.n8nAgent')"
:description="i18n.baseText('settings.n8nAgent.description')"
:show-docs-link="false"
/>
:docs-url="DOCS_URL"
:docs-label="i18n.baseText('settings.n8nAgent.docsLabel')"
>
<template #titleTrailing>
<N8nPreviewTag size="medium" />
</template>
</N8nSettingsPageHeader>
<N8nLoading v-if="store.isLoading" :rows="3" :shrink-last="false" />
<N8nEmptyState
v-else-if="!isEnabled"
v-else-if="neverConfigured"
:icon="emptyStateIcon"
:heading="i18n.baseText('settings.n8nAgent.empty.title')"
:description="i18n.baseText('settings.n8nAgent.empty.description')"
:button-text="isAdmin ? i18n.baseText('settings.n8nAgent.empty.enable') : undefined"
:button-disabled="store.isSaving || isTestingCredential"
button-variant="solid"
@click:button="handleEnable"
/>
>
<template v-if="isAdmin" #additionalContent>
<N8nText size="small" color="text-light">
{{ i18n.baseText('settings.n8nAgent.empty.footnote') }}
</N8nText>
</template>
</N8nEmptyState>
<template v-else>
<N8nSettingsSection v-if="isAdmin">
<template v-else-if="isAdmin">
<N8nSettingsSection>
<N8nSettingsRowGroup>
<N8nSettingsRow
:title="i18n.baseText('settings.n8nAgent.status.label')"
:description="i18n.baseText('settings.n8nAgent.status.description')"
:title="i18n.baseText('settings.n8nAgent.enable.label')"
:description="i18n.baseText('settings.n8nAgent.enable.description')"
>
<template #action>
<N8nButton
v-if="isOff"
variant="solid"
size="medium"
:label="i18n.baseText('settings.n8nAgent.status.enable')"
:disabled="store.isSaving || isTestingCredential"
data-test-id="n8n-agent-enable-button"
@click="handleEnable"
/>
<N8nDropdownMenu
v-else
:items="disableMenuItems"
placement="bottom-end"
data-test-id="n8n-agent-status-menu"
@@ -209,8 +473,18 @@ function handlePermissionChange(key: keyof InstanceAiPermissions, value: Instanc
:aria-label="i18n.baseText('settings.n8nAgent.status.manage')"
>
<span :class="$style.statusLabel">
<span :class="$style.statusDot" aria-hidden="true" />
{{ i18n.baseText('settings.n8nAgent.status.enabled') }}
<span
:class="[
$style.statusDot,
isSetupRequired ? $style.statusDotWarning : $style.statusDotSuccess,
]"
aria-hidden="true"
/>
{{
isSetupRequired
? i18n.baseText('settings.n8nAgent.status.setupRequired')
: i18n.baseText('settings.n8nAgent.status.enabled')
}}
<N8nIcon icon="chevron-down" size="small" />
</span>
</N8nButton>
@@ -231,35 +505,123 @@ function handlePermissionChange(key: keyof InstanceAiPermissions, value: Instanc
</N8nSettingsRow>
<N8nSettingsRow
v-if="showCredentialsRow"
:title="i18n.baseText('settings.n8nAgent.credentials.label')"
:description="i18n.baseText('settings.n8nAgent.credentials.description')"
clickable
data-test-id="n8n-agent-credentials-row"
@click="openCredentialsSettings"
v-if="showCredentialsRows"
:class="{ [$style.dim]: isOff }"
:clickable="!isOff && isModelConfigured"
data-test-id="n8n-agent-model-row"
@click="openModelDialog"
>
<template #action>
<N8nSettingsRowConfigure />
<template #info>
<N8nText bold size="medium" color="text-dark">
{{ i18n.baseText('settings.n8nAgent.modelCredential.label') }}
</N8nText>
<N8nText
v-if="modelDescription"
size="small"
:color="modelDescription.warning ? 'warning' : 'text-light'"
>
{{ i18n.baseText(modelDescription.key) }}
</N8nText>
</template>
<template v-if="!isOff" #action>
<N8nButton
v-if="!isModelConfigured"
variant="solid"
size="medium"
:label="i18n.baseText('settings.n8nAgent.modelCredential.add')"
:disabled="store.isSaving"
data-test-id="n8n-agent-model-add"
@click="openModelSetup"
/>
<N8nSettingsRowConfigure v-else :value="modelValue" />
</template>
</N8nSettingsRow>
<N8nSettingsRow
v-if="showCredentialsRows"
:class="{ [$style.dim]: isOff }"
:clickable="!isOff && isSandboxConfigured"
data-test-id="n8n-agent-sandbox-row"
@click="openSandboxDialog"
>
<template #info>
<N8nText bold size="medium" color="text-dark">
{{ i18n.baseText('settings.n8nAgent.sandbox.label') }}
</N8nText>
<N8nText size="small" :color="sandboxDescription.warning ? 'warning' : 'text-light'">
{{ i18n.baseText(sandboxDescription.key) }}
</N8nText>
</template>
<template v-if="!isOff" #action>
<N8nButton
v-if="!isSandboxConfigured"
variant="solid"
size="medium"
:label="i18n.baseText('settings.n8nAgent.sandbox.add')"
:disabled="store.isSaving"
data-test-id="n8n-agent-sandbox-add"
@click="openSandboxDialog"
/>
<N8nSettingsRowConfigure v-else :value="sandboxValue" />
</template>
</N8nSettingsRow>
</N8nSettingsRowGroup>
</N8nSettingsSection>
<N8nSettingsSection
v-if="isAdmin && showCapabilitiesSection"
v-if="showCredentialsRows || isComputerUseExperimentEnabled || isBrowserUseEnabled"
:title="i18n.baseText('settings.n8nAgent.capabilities.title')"
:description="i18n.baseText('settings.n8nAgent.capabilities.description')"
>
<N8nSettingsRowGroup>
<N8nSettingsRow
v-if="showCredentialsRows"
:class="{ [$style.dim]: isOff }"
:clickable="!isOff && searchState !== 'notset'"
data-test-id="n8n-agent-search-row"
@click="activeDialog = 'search'"
>
<template #info>
<span :class="$style.titleWithTag">
<N8nText bold size="medium" color="text-dark">
{{ i18n.baseText('settings.n8nAgent.search.label') }}
</N8nText>
<N8nBadge theme="success" size="xsmall">
{{ i18n.baseText('settings.n8nAgent.search.recommended') }}
</N8nBadge>
</span>
<N8nText size="small" color="text-light">
{{
searchState === 'env'
? i18n.baseText('settings.n8nAgent.search.env.description')
: i18n.baseText('settings.n8nAgent.search.description')
}}
</N8nText>
</template>
<template v-if="!isOff" #action>
<N8nButton
v-if="searchState === 'notset'"
variant="outline"
size="medium"
:label="i18n.baseText('settings.n8nAgent.search.setup')"
:disabled="store.isSaving"
data-test-id="n8n-agent-search-setup"
@click="activeDialog = 'search'"
/>
<N8nSettingsRowConfigure v-else :value="searchValue" />
</template>
</N8nSettingsRow>
<N8nSettingsRow
v-if="isComputerUseExperimentEnabled"
:class="{ [$style.dim]: isOff }"
:title="i18n.baseText('settings.n8nAgent.computerUse.label')"
:description="i18n.baseText('settings.n8nAgent.computerUse.description')"
>
<template #action>
<N8nSwitch
:model-value="!(store.settings?.localGatewayDisabled ?? false)"
:disabled="store.isSaving"
:disabled="store.isSaving || isOff"
:aria-label="i18n.baseText('settings.n8nAgent.computerUse.label')"
data-test-id="n8n-agent-computer-use-toggle"
@update:model-value="handleComputerUseToggle"
@@ -269,97 +631,141 @@ function handlePermissionChange(key: keyof InstanceAiPermissions, value: Instanc
<N8nSettingsRow
v-if="isBrowserUseEnabled"
:class="{ [$style.dim]: isOff }"
:title="i18n.baseText('settings.n8nAgent.browserUse.label')"
:description="i18n.baseText('settings.n8nAgent.browserUse.description')"
>
<template #action>
<N8nSwitch
:model-value="store.settings?.browserUseEnabled ?? true"
:disabled="store.isSaving"
:disabled="store.isSaving || isOff"
:aria-label="i18n.baseText('settings.n8nAgent.browserUse.label')"
data-test-id="n8n-agent-browser-use-toggle"
@update:model-value="handleBrowserUseToggle"
/>
</template>
</N8nSettingsRow>
</N8nSettingsRowGroup>
</N8nSettingsSection>
<N8nSettingsSection
v-if="isMcpConnectionsExperimentEnabled"
:title="i18n.baseText('settings.n8nAgent.mcp.title')"
:description="i18n.baseText('settings.n8nAgent.mcp.description')"
>
<N8nSettingsRowGroup>
<N8nSettingsRow
v-if="isMcpConnectionsExperimentEnabled"
:class="{ [$style.dim]: isOff }"
:title="i18n.baseText('settings.n8nAgent.mcpAccess.label')"
:description="i18n.baseText('settings.n8nAgent.mcpAccess.description')"
>
<template #action>
<N8nSwitch
:model-value="isMcpAccessEnabled"
:disabled="store.isSaving"
:disabled="store.isSaving || isOff"
:aria-label="i18n.baseText('settings.n8nAgent.mcpAccess.label')"
data-test-id="n8n-agent-mcp-access-toggle"
@update:model-value="handleMcpAccessToggle"
/>
</template>
</N8nSettingsRow>
</N8nSettingsRowGroup>
</N8nSettingsSection>
<N8nSettingsSection
:title="i18n.baseText('settings.n8nAgent.permissions.title')"
:description="i18n.baseText('settings.n8nAgent.permissions.description')"
>
<N8nSettingsRowGroup>
<N8nSettingsRow
v-if="isMcpConnectionsExperimentEnabled && isMcpAccessEnabled"
:title="i18n.baseText('settings.n8nAgent.permissions.executeMcpTool')"
v-for="group in permissionGroups"
:key="group.id"
v-model="expandedGroups[group.id]"
:class="{ [$style.dim]: isGroupLocked(group) }"
:title="i18n.baseText(group.labelKey)"
:expandable="!isGroupLocked(group)"
:expand-label="groupSummary(group)"
:collapse-label="groupSummary(group)"
:data-test-id="`n8n-agent-permission-group-${group.id}`"
>
<template #action>
<N8nSelect
:class="$style.permissionSelect"
:model-value="store.getPermission('executeMcpTool')"
size="small"
:disabled="store.isSaving"
data-test-id="n8n-agent-permission-executeMcpTool"
@update:model-value="
handlePermissionChange('executeMcpTool', $event as InstanceAiPermissionMode)
"
>
<N8nOption
v-for="option in MCP_TOOL_PERMISSION_OPTIONS"
:key="option"
:value="option"
:label="i18n.baseText(PERMISSION_OPTION_LABEL[option])"
/>
</N8nSelect>
<template v-if="isGroupLocked(group)" #action>
<N8nText size="small" color="text-light">{{ groupSummary(group) }}</N8nText>
</template>
<template #expanded>
<div :class="$style.permissionList">
<div v-for="key in group.keys" :key="key" :class="$style.permissionRow">
<N8nText size="small" color="text-dark">
{{ i18n.baseText(`settings.n8nAgent.permissions.${key}` as BaseTextKey) }}
</N8nText>
<N8nSelect
:class="$style.permissionSelect"
:model-value="store.getPermission(key)"
size="small"
:disabled="store.isSaving || isGroupLocked(group)"
:data-test-id="`n8n-agent-permission-${key}`"
@update:model-value="
handlePermissionChange(key, $event as InstanceAiPermissionMode)
"
>
<N8nOption
v-for="option in permissionOptionsFor(key)"
:key="option"
:value="option"
:label="i18n.baseText(PERMISSION_OPTION_LABEL[option])"
/>
</N8nSelect>
</div>
</div>
</template>
</N8nSettingsRow>
</N8nSettingsRowGroup>
</N8nSettingsSection>
<N8nSettingsSection
v-if="isAdmin"
:title="i18n.baseText('settings.n8nAgent.permissions.title')"
:description="i18n.baseText('settings.n8nAgent.permissions.description')"
:title="i18n.baseText('settings.n8nAgent.dataSharing.title')"
:description="i18n.baseText('settings.n8nAgent.dataSharing.description')"
>
<N8nSettingsRowGroup>
<N8nSettingsRow
v-for="permission in permissionKeys"
:key="permission.key"
:title="i18n.baseText(permission.labelKey)"
:class="{ [$style.dim]: isOff }"
:title="i18n.baseText('settings.n8nAgent.dataSharing.manage.label')"
:description="i18n.baseText('settings.n8nAgent.dataSharing.manage.description')"
:clickable="!isOff && store.canManageAiUsage"
data-test-id="n8n-agent-data-sharing-row"
@click="openAiUsageSettings"
>
<template #action>
<N8nSelect
:class="$style.permissionSelect"
:model-value="store.getPermission(permission.key)"
size="small"
:disabled="store.isSaving"
:data-test-id="`n8n-agent-permission-${permission.key}`"
@update:model-value="
handlePermissionChange(permission.key, $event as InstanceAiPermissionMode)
"
>
<N8nOption
v-for="option in PERMISSION_OPTIONS"
:key="option"
:value="option"
:label="i18n.baseText(PERMISSION_OPTION_LABEL[option])"
/>
</N8nSelect>
<template v-if="!isOff && store.canManageAiUsage" #action>
<N8nSettingsRowConfigure />
</template>
</N8nSettingsRow>
</N8nSettingsRowGroup>
</N8nSettingsSection>
</template>
<template v-if="showCredentialsRows">
<ConnectionDialog
kind="model"
:open="activeDialog === 'model'"
:setup="setupChain"
@update:open="setDialogOpen('model', $event)"
@saved="handleModelSaved"
/>
<ConnectionDialog
kind="sandbox"
:open="activeDialog === 'sandbox'"
:setup="setupChain"
@update:open="setDialogOpen('sandbox', $event)"
@saved="handleSandboxSaved"
@back="activeDialog = 'model'"
/>
<ConnectionDialog
kind="search"
:open="activeDialog === 'search'"
:setup="setupChain"
@update:open="setDialogOpen('search', $event)"
@back="activeDialog = 'sandbox'"
/>
</template>
</N8nSettingsLayout>
</template>
@@ -374,13 +780,45 @@ function handlePermissionChange(key: keyof InstanceAiPermissions, value: Instanc
width: var(--spacing--2xs);
height: var(--spacing--2xs);
border-radius: var(--radius--full);
}
.statusDotSuccess {
background: var(--text-color--success);
}
.statusDotWarning {
background: var(--text-color--warning);
}
.danger {
color: var(--text-color--danger);
}
.titleWithTag {
display: inline-flex;
align-items: center;
gap: var(--spacing--2xs);
}
.dim {
opacity: 0.5;
pointer-events: none;
}
.permissionList {
display: flex;
flex-direction: column;
gap: var(--spacing--3xs);
padding: 0 0 var(--spacing--2xs) var(--spacing--sm);
}
.permissionRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing--sm);
}
.permissionSelect {
width: 11rem;
}