mirror of
https://github.com/n8n-io/n8n
synced 2026-04-21 15:47:20 +00:00
fix(core): Rework Instance ai settings (no-changelog) (#28495)
This commit is contained in:
parent
cff2852332
commit
5c9a732af4
18 changed files with 1189 additions and 151 deletions
|
|
@ -282,6 +282,7 @@ export type FrontendModuleSettings = {
|
|||
localGatewayDisabled: boolean;
|
||||
proxyEnabled: boolean;
|
||||
optinModalDismissed: boolean;
|
||||
cloudManaged: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -278,8 +278,6 @@ export {
|
|||
workflowSetupNodeSchema,
|
||||
errorPayloadSchema,
|
||||
filesystemRequestPayloadSchema,
|
||||
instanceAiFilesystemResponseSchema,
|
||||
instanceAiGatewayCapabilitiesSchema,
|
||||
mcpToolSchema,
|
||||
mcpToolCallRequestSchema,
|
||||
mcpToolCallResultSchema,
|
||||
|
|
@ -302,6 +300,8 @@ export {
|
|||
InstanceAiThreadMessagesQuery,
|
||||
InstanceAiAdminSettingsUpdateRequest,
|
||||
InstanceAiUserPreferencesUpdateRequest,
|
||||
InstanceAiGatewayCapabilitiesDto,
|
||||
InstanceAiFilesystemResponseDto,
|
||||
applyBranchReadOnlyOverrides,
|
||||
} from './schemas/instance-ai.schema';
|
||||
|
||||
|
|
|
|||
|
|
@ -431,13 +431,13 @@ export const toolCategorySchema = z.object({
|
|||
});
|
||||
export type ToolCategory = z.infer<typeof toolCategorySchema>;
|
||||
|
||||
export const instanceAiGatewayCapabilitiesSchema = z.object({
|
||||
export class InstanceAiGatewayCapabilitiesDto extends Z.class({
|
||||
rootPath: z.string(),
|
||||
tools: z.array(mcpToolSchema).default([]),
|
||||
hostIdentifier: z.string().optional(),
|
||||
toolCategories: z.array(toolCategorySchema).default([]),
|
||||
});
|
||||
export type InstanceAiGatewayCapabilities = z.infer<typeof instanceAiGatewayCapabilitiesSchema>;
|
||||
}) {}
|
||||
export type InstanceAiGatewayCapabilities = InstanceType<typeof InstanceAiGatewayCapabilitiesDto>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Filesystem bridge payloads (browser ↔ server round-trip)
|
||||
|
|
@ -448,10 +448,10 @@ export const filesystemRequestPayloadSchema = z.object({
|
|||
toolCall: mcpToolCallRequestSchema,
|
||||
});
|
||||
|
||||
export const instanceAiFilesystemResponseSchema = z.object({
|
||||
export class InstanceAiFilesystemResponseDto extends Z.class({
|
||||
result: mcpToolCallResultSchema.optional(),
|
||||
error: z.string().optional(),
|
||||
});
|
||||
}) {}
|
||||
|
||||
export const tasksUpdatePayloadSchema = z.object({
|
||||
tasks: taskListSchema,
|
||||
|
|
@ -544,7 +544,7 @@ export type InstanceAiThreadTitleUpdatedEvent = Extract<
|
|||
{ type: 'thread-title-updated' }
|
||||
>;
|
||||
|
||||
export type InstanceAiFilesystemResponse = z.infer<typeof instanceAiFilesystemResponseSchema>;
|
||||
export type InstanceAiFilesystemResponse = InstanceType<typeof InstanceAiFilesystemResponseDto>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// API types
|
||||
|
|
|
|||
|
|
@ -10,7 +10,10 @@ import type { CredentialsService } from '@/credentials/credentials.service';
|
|||
import { InstanceAiSettingsService } from '../instance-ai-settings.service';
|
||||
|
||||
describe('InstanceAiSettingsService', () => {
|
||||
const globalConfig = mock<{ instanceAi: InstanceAiConfig }>({
|
||||
const globalConfig = mock<{
|
||||
instanceAi: InstanceAiConfig;
|
||||
deployment: { type: string };
|
||||
}>({
|
||||
instanceAi: {
|
||||
lastMessages: 10,
|
||||
model: 'openai/gpt-4',
|
||||
|
|
@ -27,6 +30,7 @@ describe('InstanceAiSettingsService', () => {
|
|||
sandboxTimeout: 60,
|
||||
localGatewayDisabled: false,
|
||||
} as unknown as InstanceAiConfig,
|
||||
deployment: { type: 'default' },
|
||||
});
|
||||
const settingsRepository = mock<SettingsRepository>();
|
||||
const aiService = mock<AiService>();
|
||||
|
|
@ -115,4 +119,88 @@ describe('InstanceAiSettingsService', () => {
|
|||
).resolves.toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('cloud-managed fields', () => {
|
||||
beforeEach(() => {
|
||||
globalConfig.deployment.type = 'cloud';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalConfig.deployment.type = 'default';
|
||||
});
|
||||
|
||||
describe('updateAdminSettings', () => {
|
||||
it('should reject memory fields on cloud', async () => {
|
||||
await expect(service.updateAdminSettings({ lastMessages: 50 })).rejects.toThrow(
|
||||
UnprocessableRequestError,
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject advanced fields on cloud', async () => {
|
||||
await expect(service.updateAdminSettings({ subAgentMaxSteps: 50 })).rejects.toThrow(
|
||||
UnprocessableRequestError,
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject sandbox fields on cloud', async () => {
|
||||
await expect(service.updateAdminSettings({ sandboxEnabled: true })).rejects.toThrow(
|
||||
UnprocessableRequestError,
|
||||
);
|
||||
});
|
||||
|
||||
it('should include cloud-managed label in error message', async () => {
|
||||
await expect(service.updateAdminSettings({ lastMessages: 50 })).rejects.toThrow(
|
||||
/cloud-managed/,
|
||||
);
|
||||
});
|
||||
|
||||
it('should allow enabled toggle on cloud', async () => {
|
||||
settingsRepository.upsert.mockResolvedValue(undefined as never);
|
||||
|
||||
await expect(service.updateAdminSettings({ enabled: true })).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it('should allow permissions on cloud', async () => {
|
||||
settingsRepository.upsert.mockResolvedValue(undefined as never);
|
||||
|
||||
await expect(
|
||||
service.updateAdminSettings({
|
||||
permissions: { createWorkflow: 'always_allow' },
|
||||
}),
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it('should allow localGatewayDisabled on cloud', async () => {
|
||||
settingsRepository.upsert.mockResolvedValue(undefined as never);
|
||||
|
||||
await expect(
|
||||
service.updateAdminSettings({ localGatewayDisabled: true }),
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateUserPreferences', () => {
|
||||
const user = mock<User>({ id: 'user-1' });
|
||||
|
||||
it('should reject credentialId on cloud', async () => {
|
||||
await expect(
|
||||
service.updateUserPreferences(user, { credentialId: 'cred-1' }),
|
||||
).rejects.toThrow(UnprocessableRequestError);
|
||||
});
|
||||
|
||||
it('should reject modelName on cloud', async () => {
|
||||
await expect(service.updateUserPreferences(user, { modelName: 'gpt-4' })).rejects.toThrow(
|
||||
UnprocessableRequestError,
|
||||
);
|
||||
});
|
||||
|
||||
it('should allow localGatewayDisabled on cloud', async () => {
|
||||
settingsRepository.upsert.mockResolvedValue(undefined as never);
|
||||
|
||||
await expect(
|
||||
service.updateUserPreferences(user, { localGatewayDisabled: true }),
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ jest.mock('../eval/execution.service', () => ({
|
|||
}));
|
||||
|
||||
import type {
|
||||
InstanceAiAdminSettingsUpdateRequest,
|
||||
InstanceAiSendMessageRequest,
|
||||
InstanceAiCorrectTaskRequest,
|
||||
InstanceAiConfirmRequestDto,
|
||||
|
|
@ -44,14 +45,15 @@ import type { Scope } from '@n8n/permissions';
|
|||
import type { Request, Response } from 'express';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
|
||||
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||
import { ConflictError } from '@/errors/response-errors/conflict.error';
|
||||
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
|
||||
import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
||||
import type { Push } from '@/push';
|
||||
import type { UrlService } from '@/services/url.service';
|
||||
|
||||
import type { EvalExecutionService } from '../eval/execution.service';
|
||||
import type { InProcessEventBus } from '../event-bus/in-process-event-bus';
|
||||
import type { LocalGateway } from '../filesystem/local-gateway';
|
||||
import type { InstanceAiMemoryService } from '../instance-ai-memory.service';
|
||||
import type { InstanceAiSettingsService } from '../instance-ai-settings.service';
|
||||
import { InstanceAiController } from '../instance-ai.controller';
|
||||
|
|
@ -78,6 +80,7 @@ describe('InstanceAiController', () => {
|
|||
const eventBus = mock<InProcessEventBus>();
|
||||
const moduleRegistry = mock<ModuleRegistry>();
|
||||
const push = mock<Push>();
|
||||
const urlService = mock<UrlService>();
|
||||
const globalConfig = mock<GlobalConfig>({
|
||||
instanceAi: { gatewayApiKey: 'static-key' },
|
||||
editorBaseUrl: 'http://localhost:5678',
|
||||
|
|
@ -92,6 +95,7 @@ describe('InstanceAiController', () => {
|
|||
eventBus,
|
||||
moduleRegistry,
|
||||
push,
|
||||
urlService,
|
||||
globalConfig,
|
||||
);
|
||||
|
||||
|
|
@ -479,6 +483,51 @@ describe('InstanceAiController', () => {
|
|||
globalOnly: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should disconnect all gateways when enabled is set to false', async () => {
|
||||
settingsService.updateAdminSettings.mockResolvedValue({} as never);
|
||||
instanceAiService.disconnectAllGateways.mockReturnValue(['user-a', 'user-b']);
|
||||
const payload = { enabled: false } as InstanceAiAdminSettingsUpdateRequest;
|
||||
|
||||
await controller.updateAdminSettings(req, res, payload);
|
||||
|
||||
expect(instanceAiService.disconnectAllGateways).toHaveBeenCalled();
|
||||
expect(push.sendToUsers).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'instanceAiGatewayStateChanged',
|
||||
data: { connected: false, directory: null, hostIdentifier: null, toolCategories: [] },
|
||||
}),
|
||||
['user-a', 'user-b'],
|
||||
);
|
||||
});
|
||||
|
||||
it('should disconnect all gateways when localGatewayDisabled is set to true', async () => {
|
||||
settingsService.updateAdminSettings.mockResolvedValue({} as never);
|
||||
instanceAiService.disconnectAllGateways.mockReturnValue(['user-c']);
|
||||
const payload = { localGatewayDisabled: true } as InstanceAiAdminSettingsUpdateRequest;
|
||||
|
||||
await controller.updateAdminSettings(req, res, payload);
|
||||
|
||||
expect(instanceAiService.disconnectAllGateways).toHaveBeenCalled();
|
||||
expect(push.sendToUsers).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'instanceAiGatewayStateChanged',
|
||||
}),
|
||||
['user-c'],
|
||||
);
|
||||
});
|
||||
|
||||
it('should not disconnect gateways when enabling features', async () => {
|
||||
settingsService.updateAdminSettings.mockResolvedValue({} as never);
|
||||
const payload = {
|
||||
enabled: true,
|
||||
localGatewayDisabled: false,
|
||||
} as InstanceAiAdminSettingsUpdateRequest;
|
||||
|
||||
await controller.updateAdminSettings(req, res, payload);
|
||||
|
||||
expect(instanceAiService.disconnectAllGateways).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUserPreferences', () => {
|
||||
|
|
@ -693,12 +742,13 @@ describe('InstanceAiController', () => {
|
|||
|
||||
it('should return token and command', async () => {
|
||||
instanceAiService.generatePairingToken.mockReturnValue('pairing-token');
|
||||
urlService.getInstanceBaseUrl.mockReturnValue('https://myinstance.n8n.cloud');
|
||||
|
||||
const result = await controller.createGatewayLink(req);
|
||||
|
||||
expect(result).toEqual({
|
||||
token: 'pairing-token',
|
||||
command: 'npx @n8n/computer-use http://localhost:5678 pairing-token',
|
||||
command: 'npx @n8n/computer-use https://myinstance.n8n.cloud pairing-token',
|
||||
});
|
||||
expect(instanceAiService.generatePairingToken).toHaveBeenCalledWith(USER_ID);
|
||||
});
|
||||
|
|
@ -712,12 +762,13 @@ describe('InstanceAiController', () => {
|
|||
expect(scopeOf('gatewayInit')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should initialize gateway with valid key and body', () => {
|
||||
it('should initialize gateway with valid key and body', async () => {
|
||||
instanceAiService.getUserIdForApiKey.mockReturnValue(USER_ID);
|
||||
instanceAiService.consumePairingToken.mockReturnValue(null);
|
||||
const gatewayReq = makeGatewayReq('session-key', { rootPath: '/home/user' });
|
||||
const payload = { rootPath: '/home/user', tools: [], toolCategories: [] };
|
||||
|
||||
const result = controller.gatewayInit(gatewayReq);
|
||||
const result = await controller.gatewayInit(gatewayReq, res, payload);
|
||||
|
||||
expect(result).toEqual({ ok: true });
|
||||
expect(instanceAiService.initGateway).toHaveBeenCalledWith(
|
||||
|
|
@ -738,51 +789,103 @@ describe('InstanceAiController', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('should return sessionKey when pairing token is consumed', () => {
|
||||
it('should return sessionKey when pairing token is consumed', async () => {
|
||||
instanceAiService.getUserIdForApiKey.mockReturnValue(USER_ID);
|
||||
instanceAiService.consumePairingToken.mockReturnValue('new-session-key');
|
||||
const gatewayReq = makeGatewayReq('pairing-token', { rootPath: '/tmp' });
|
||||
|
||||
const result = controller.gatewayInit(gatewayReq);
|
||||
const result = await controller.gatewayInit(gatewayReq, res, {
|
||||
rootPath: '/tmp',
|
||||
tools: [],
|
||||
toolCategories: [],
|
||||
});
|
||||
|
||||
expect(result).toEqual({ ok: true, sessionKey: 'new-session-key' });
|
||||
});
|
||||
|
||||
it('should accept static env var key', () => {
|
||||
it('should accept static env var key', async () => {
|
||||
instanceAiService.consumePairingToken.mockReturnValue(null);
|
||||
const gatewayReq = makeGatewayReq('static-key', { rootPath: '/tmp' });
|
||||
|
||||
const result = controller.gatewayInit(gatewayReq);
|
||||
const result = await controller.gatewayInit(gatewayReq, res, {
|
||||
rootPath: '/tmp',
|
||||
tools: [],
|
||||
toolCategories: [],
|
||||
});
|
||||
|
||||
expect(result).toEqual({ ok: true });
|
||||
expect(instanceAiService.initGateway).toHaveBeenCalledWith('env-gateway', expect.anything());
|
||||
});
|
||||
|
||||
it('should throw ForbiddenError with missing API key', () => {
|
||||
it('should throw ForbiddenError with missing API key', async () => {
|
||||
const gatewayReq = makeGatewayReq(undefined, { rootPath: '/tmp' });
|
||||
|
||||
expect(() => controller.gatewayInit(gatewayReq)).toThrow(ForbiddenError);
|
||||
await expect(
|
||||
controller.gatewayInit(gatewayReq, res, {
|
||||
rootPath: '/tmp',
|
||||
tools: [],
|
||||
toolCategories: [],
|
||||
}),
|
||||
).rejects.toThrow(ForbiddenError);
|
||||
});
|
||||
|
||||
it('should throw ForbiddenError with invalid API key', () => {
|
||||
it('should throw ForbiddenError with invalid API key', async () => {
|
||||
instanceAiService.getUserIdForApiKey.mockReturnValue(undefined);
|
||||
const gatewayReq = makeGatewayReq('wrong-key', { rootPath: '/tmp' });
|
||||
|
||||
expect(() => controller.gatewayInit(gatewayReq)).toThrow(ForbiddenError);
|
||||
});
|
||||
|
||||
it('should throw BadRequestError when body fails schema validation', () => {
|
||||
instanceAiService.getUserIdForApiKey.mockReturnValue(USER_ID);
|
||||
const gatewayReq = makeGatewayReq('session-key', { unexpected: 123 });
|
||||
|
||||
expect(() => controller.gatewayInit(gatewayReq)).toThrow(BadRequestError);
|
||||
await expect(
|
||||
controller.gatewayInit(gatewayReq, res, {
|
||||
rootPath: '/tmp',
|
||||
tools: [],
|
||||
toolCategories: [],
|
||||
}),
|
||||
).rejects.toThrow(ForbiddenError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('gatewayEvents', () => {
|
||||
const makeGatewayReq = (key: string) =>
|
||||
({
|
||||
headers: { 'x-gateway-key': key },
|
||||
once: jest.fn(),
|
||||
}) as unknown as Request;
|
||||
|
||||
const makeFlushableRes = () => {
|
||||
const res = {
|
||||
setHeader: jest.fn(),
|
||||
flushHeaders: jest.fn(),
|
||||
write: jest.fn(),
|
||||
flush: jest.fn(),
|
||||
once: jest.fn(),
|
||||
};
|
||||
return res as unknown as Parameters<typeof controller.gatewayEvents>[1];
|
||||
};
|
||||
|
||||
it('should have no access scope (skipAuth)', () => {
|
||||
expect(scopeOf('gatewayEvents')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should reject with ForbiddenError when the gateway has not been initialized', async () => {
|
||||
instanceAiService.getUserIdForApiKey.mockReturnValue(USER_ID);
|
||||
instanceAiService.getLocalGateway.mockReturnValue(mock<LocalGateway>({ isConnected: false }));
|
||||
|
||||
await expect(
|
||||
controller.gatewayEvents(makeGatewayReq('session-key'), makeFlushableRes()),
|
||||
).rejects.toThrow(ForbiddenError);
|
||||
|
||||
expect(instanceAiService.clearDisconnectTimer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should clear a pending disconnect timer when SSE reconnects while still connected', async () => {
|
||||
instanceAiService.getUserIdForApiKey.mockReturnValue(USER_ID);
|
||||
const gateway = mock<LocalGateway>({ isConnected: true });
|
||||
gateway.onRequest.mockReturnValue(() => {});
|
||||
instanceAiService.getLocalGateway.mockReturnValue(gateway);
|
||||
|
||||
await controller.gatewayEvents(makeGatewayReq('session-key'), makeFlushableRes());
|
||||
|
||||
expect(instanceAiService.clearDisconnectTimer).toHaveBeenCalledWith(USER_ID);
|
||||
});
|
||||
});
|
||||
|
||||
describe('gatewayResponse', () => {
|
||||
|
|
@ -798,7 +901,9 @@ describe('InstanceAiController', () => {
|
|||
instanceAiService.resolveGatewayRequest.mockReturnValue(true);
|
||||
const gatewayReq = makeGatewayReq('session-key', { result: { content: [] } });
|
||||
|
||||
const result = controller.gatewayResponse(gatewayReq, res, 'req-1');
|
||||
const result = controller.gatewayResponse(gatewayReq, res, 'req-1', {
|
||||
result: { content: [] },
|
||||
});
|
||||
|
||||
expect(result).toEqual({ ok: true });
|
||||
expect(instanceAiService.resolveGatewayRequest).toHaveBeenCalledWith(
|
||||
|
|
@ -814,14 +919,9 @@ describe('InstanceAiController', () => {
|
|||
instanceAiService.resolveGatewayRequest.mockReturnValue(false);
|
||||
const gatewayReq = makeGatewayReq('session-key', { result: { content: [] } });
|
||||
|
||||
expect(() => controller.gatewayResponse(gatewayReq, res, 'req-1')).toThrow(NotFoundError);
|
||||
});
|
||||
|
||||
it('should throw BadRequestError when body fails schema validation', () => {
|
||||
instanceAiService.getUserIdForApiKey.mockReturnValue(USER_ID);
|
||||
const gatewayReq = makeGatewayReq('session-key', { result: 'not-an-object' });
|
||||
|
||||
expect(() => controller.gatewayResponse(gatewayReq, res, 'req-1')).toThrow(BadRequestError);
|
||||
expect(() =>
|
||||
controller.gatewayResponse(gatewayReq, res, 'req-1', { result: { content: [] } }),
|
||||
).toThrow(NotFoundError);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -870,7 +970,7 @@ describe('InstanceAiController', () => {
|
|||
body: { result: { content: [] } },
|
||||
} as unknown as Request;
|
||||
|
||||
controller.gatewayResponse(gatewayReq, res, 'req-1');
|
||||
controller.gatewayResponse(gatewayReq, res, 'req-1', { result: { content: [] } });
|
||||
|
||||
// validateGatewayApiKey receives 'key1' (the first element)
|
||||
expect(instanceAiService.getUserIdForApiKey).toHaveBeenCalledWith('key1');
|
||||
|
|
|
|||
|
|
@ -196,6 +196,13 @@ export class LocalGatewayRegistry {
|
|||
state.disconnectTimer = null;
|
||||
}
|
||||
|
||||
/** Return IDs of users with an active gateway connection. */
|
||||
getConnectedUserIds(): string[] {
|
||||
return [...this.userGateways.entries()]
|
||||
.filter(([, state]) => state.gateway.getStatus().connected)
|
||||
.map(([userId]) => userId);
|
||||
}
|
||||
|
||||
/** Disconnect all gateways and clear all state (called on service shutdown). */
|
||||
disconnectAll(): void {
|
||||
for (const state of this.userGateways.values()) {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { GlobalConfig } from '@n8n/config';
|
||||
import type { InstanceAiConfig } from '@n8n/config';
|
||||
import type { InstanceAiConfig, DeploymentConfig } from '@n8n/config';
|
||||
import { SettingsRepository } from '@n8n/db';
|
||||
import type { User } from '@n8n/db';
|
||||
import { Service } from '@n8n/di';
|
||||
|
|
@ -88,6 +88,8 @@ interface PersistedUserPreferences {
|
|||
export class InstanceAiSettingsService {
|
||||
private readonly config: InstanceAiConfig;
|
||||
|
||||
private readonly deploymentConfig: DeploymentConfig;
|
||||
|
||||
/** Whether n8n Agent is enabled for this instance. */
|
||||
private enabled = true;
|
||||
|
||||
|
|
@ -114,6 +116,12 @@ export class InstanceAiSettingsService {
|
|||
private readonly credentialsFinderService: CredentialsFinderService,
|
||||
) {
|
||||
this.config = globalConfig.instanceAi;
|
||||
this.deploymentConfig = globalConfig.deployment;
|
||||
}
|
||||
|
||||
/** Whether this instance is running on the cloud platform. */
|
||||
private get isCloud(): boolean {
|
||||
return this.deploymentConfig.type === 'cloud';
|
||||
}
|
||||
|
||||
/** Whether the AI service proxy is active (model, search, sandbox managed externally). */
|
||||
|
|
@ -160,8 +168,18 @@ export class InstanceAiSettingsService {
|
|||
async updateAdminSettings(
|
||||
update: InstanceAiAdminSettingsUpdateRequest,
|
||||
): Promise<InstanceAiAdminSettingsResponse> {
|
||||
if (this.aiService.isProxyEnabled()) {
|
||||
this.rejectProxyManagedFields(update, InstanceAiSettingsService.PROXY_MANAGED_ADMIN_FIELDS);
|
||||
if (this.isCloud) {
|
||||
this.rejectManagedFields(
|
||||
update,
|
||||
InstanceAiSettingsService.CLOUD_MANAGED_ADMIN_FIELDS,
|
||||
'cloud',
|
||||
);
|
||||
} else if (this.aiService.isProxyEnabled()) {
|
||||
this.rejectManagedFields(
|
||||
update,
|
||||
InstanceAiSettingsService.PROXY_MANAGED_ADMIN_FIELDS,
|
||||
'proxy',
|
||||
);
|
||||
}
|
||||
const c = this.config;
|
||||
if (update.enabled !== undefined) this.enabled = update.enabled;
|
||||
|
|
@ -216,8 +234,7 @@ export class InstanceAiSettingsService {
|
|||
credentialType,
|
||||
credentialName,
|
||||
modelName: prefs.modelName || this.extractModelName(this.config.model),
|
||||
localGatewayDisabled:
|
||||
this.config.localGatewayDisabled || (prefs.localGatewayDisabled ?? false),
|
||||
localGatewayDisabled: prefs.localGatewayDisabled ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -225,10 +242,17 @@ export class InstanceAiSettingsService {
|
|||
user: User,
|
||||
update: InstanceAiUserPreferencesUpdateRequest,
|
||||
): Promise<InstanceAiUserPreferencesResponse> {
|
||||
if (this.aiService.isProxyEnabled()) {
|
||||
this.rejectProxyManagedFields(
|
||||
if (this.isCloud) {
|
||||
this.rejectManagedFields(
|
||||
update,
|
||||
InstanceAiSettingsService.CLOUD_MANAGED_PREFERENCE_FIELDS,
|
||||
'cloud',
|
||||
);
|
||||
} else if (this.aiService.isProxyEnabled()) {
|
||||
this.rejectManagedFields(
|
||||
update,
|
||||
InstanceAiSettingsService.PROXY_MANAGED_PREFERENCE_FIELDS,
|
||||
'proxy',
|
||||
);
|
||||
}
|
||||
const prefs = await this.loadUserPreferences(user.id);
|
||||
|
|
@ -367,9 +391,10 @@ export class InstanceAiSettingsService {
|
|||
}
|
||||
|
||||
/** Whether the local gateway is disabled for a given user (admin override OR user preference). */
|
||||
isLocalGatewayDisabledForUser(userId: string): boolean {
|
||||
async isLocalGatewayDisabledForUser(userId: string): Promise<boolean> {
|
||||
if (!this.enabled) return true;
|
||||
if (this.config.localGatewayDisabled) return true;
|
||||
const prefs = this.userPreferences.get(userId);
|
||||
const prefs = await this.loadUserPreferences(userId);
|
||||
return prefs?.localGatewayDisabled ?? false;
|
||||
}
|
||||
|
||||
|
|
@ -455,14 +480,33 @@ export class InstanceAiSettingsService {
|
|||
'modelName',
|
||||
];
|
||||
|
||||
private rejectProxyManagedFields(
|
||||
update: Record<string, unknown>,
|
||||
/** Admin fields managed by the cloud platform — superset of proxy-managed fields. */
|
||||
private static readonly CLOUD_MANAGED_ADMIN_FIELDS: readonly string[] = [
|
||||
...InstanceAiSettingsService.PROXY_MANAGED_ADMIN_FIELDS,
|
||||
'n8nSandboxCredentialId',
|
||||
'lastMessages',
|
||||
'embedderModel',
|
||||
'semanticRecallTopK',
|
||||
'subAgentMaxSteps',
|
||||
'browserMcp',
|
||||
'mcpServers',
|
||||
];
|
||||
|
||||
/** User preference fields managed by the cloud platform. */
|
||||
private static readonly CLOUD_MANAGED_PREFERENCE_FIELDS: readonly string[] = [
|
||||
...InstanceAiSettingsService.PROXY_MANAGED_PREFERENCE_FIELDS,
|
||||
];
|
||||
|
||||
private rejectManagedFields(
|
||||
update: object,
|
||||
managedFields: readonly string[],
|
||||
label: string,
|
||||
): void {
|
||||
const present = managedFields.filter((key) => key in update && update[key] !== undefined);
|
||||
const record = update as Record<string, unknown>;
|
||||
const present = managedFields.filter((key) => key in record && record[key] !== undefined);
|
||||
if (present.length > 0) {
|
||||
throw new UnprocessableRequestError(
|
||||
`Cannot update proxy-managed fields: ${present.join(', ')}`,
|
||||
`Cannot update ${label}-managed fields: ${present.join(', ')}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import {
|
||||
InstanceAiConfirmRequestDto,
|
||||
instanceAiGatewayCapabilitiesSchema,
|
||||
instanceAiFilesystemResponseSchema,
|
||||
InstanceAiGatewayCapabilitiesDto,
|
||||
InstanceAiFilesystemResponseDto,
|
||||
InstanceAiRenameThreadRequestDto,
|
||||
InstanceAiSendMessageRequest,
|
||||
InstanceAiEventsQuery,
|
||||
|
|
@ -45,6 +45,7 @@ import { ConflictError } from '@/errors/response-errors/conflict.error';
|
|||
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
|
||||
import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
||||
import { Push } from '@/push';
|
||||
import { UrlService } from '@/services/url.service';
|
||||
|
||||
type FlushableResponse = Response & { flush?: () => void };
|
||||
|
||||
|
|
@ -54,8 +55,6 @@ const KEEP_ALIVE_INTERVAL_MS = 15_000;
|
|||
export class InstanceAiController {
|
||||
private readonly gatewayApiKey: string;
|
||||
|
||||
private readonly instanceBaseUrl: string;
|
||||
|
||||
private static getTreeRichnessScore(tree: InstanceAiAgentNode): number {
|
||||
let score = 0;
|
||||
const stack = [tree];
|
||||
|
|
@ -94,10 +93,10 @@ export class InstanceAiController {
|
|||
private readonly eventBus: InProcessEventBus,
|
||||
private readonly moduleRegistry: ModuleRegistry,
|
||||
private readonly push: Push,
|
||||
private readonly urlService: UrlService,
|
||||
globalConfig: GlobalConfig,
|
||||
) {
|
||||
this.gatewayApiKey = globalConfig.instanceAi.gatewayApiKey;
|
||||
this.instanceBaseUrl = globalConfig.editorBaseUrl || `http://localhost:${globalConfig.port}`;
|
||||
}
|
||||
|
||||
private requireInstanceAiEnabled(): void {
|
||||
|
|
@ -413,6 +412,25 @@ export class InstanceAiController {
|
|||
) {
|
||||
const result = await this.settingsService.updateAdminSettings(payload);
|
||||
await this.moduleRegistry.refreshModuleSettings('instance-ai');
|
||||
|
||||
if (payload.enabled === false || payload.localGatewayDisabled === true) {
|
||||
const disconnectedUserIds = this.instanceAiService.disconnectAllGateways();
|
||||
if (disconnectedUserIds.length > 0) {
|
||||
this.push.sendToUsers(
|
||||
{
|
||||
type: 'instanceAiGatewayStateChanged',
|
||||
data: {
|
||||
connected: false,
|
||||
directory: null,
|
||||
hostIdentifier: null,
|
||||
toolCategories: [],
|
||||
},
|
||||
},
|
||||
disconnectedUserIds,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
|
|
@ -574,8 +592,9 @@ export class InstanceAiController {
|
|||
@Post('/gateway/create-link')
|
||||
@GlobalScope('instanceAi:gateway')
|
||||
async createGatewayLink(req: AuthenticatedRequest) {
|
||||
await this.assertGatewayEnabled(req.user.id);
|
||||
const token = this.instanceAiService.generatePairingToken(req.user.id);
|
||||
const baseUrl = this.instanceBaseUrl.replace(/\/$/, '');
|
||||
const baseUrl = this.urlService.getInstanceBaseUrl();
|
||||
const command = `npx @n8n/computer-use ${baseUrl} ${token}`;
|
||||
return { token, command };
|
||||
}
|
||||
|
|
@ -583,6 +602,19 @@ export class InstanceAiController {
|
|||
@Get('/gateway/events', { usesTemplates: true, skipAuth: true })
|
||||
async gatewayEvents(req: Request, res: FlushableResponse) {
|
||||
const userId = this.validateGatewayApiKey(this.getGatewayKeyHeader(req));
|
||||
await this.assertGatewayEnabled(userId);
|
||||
|
||||
const gateway = this.instanceAiService.getLocalGateway(userId);
|
||||
|
||||
// If the grace-period timer already fired (e.g. after a long reconnect gap),
|
||||
// the gateway state is torn down. Reject so the daemon falls into its auth-error
|
||||
// reconnect branch, which re-uploads capabilities and re-establishes state.
|
||||
if (!gateway.isConnected) {
|
||||
throw new ForbiddenError('Local gateway not initialized');
|
||||
}
|
||||
|
||||
// Daemon reconnected within the grace window — cancel the pending disconnect.
|
||||
this.instanceAiService.clearDisconnectTimer(userId);
|
||||
|
||||
(res as unknown as { compress: boolean }).compress = false;
|
||||
res.setHeader('Content-Type', 'text/event-stream; charset=UTF-8');
|
||||
|
|
@ -591,7 +623,6 @@ export class InstanceAiController {
|
|||
res.setHeader('X-Accel-Buffering', 'no');
|
||||
res.flushHeaders();
|
||||
|
||||
const gateway = this.instanceAiService.getLocalGateway(userId);
|
||||
const unsubscribe = gateway.onRequest((event) => {
|
||||
res.write(`data: ${JSON.stringify(event)}\n\n`);
|
||||
res.flush?.();
|
||||
|
|
@ -625,24 +656,21 @@ export class InstanceAiController {
|
|||
}
|
||||
|
||||
@Post('/gateway/init', { skipAuth: true })
|
||||
gatewayInit(req: Request) {
|
||||
async gatewayInit(req: Request, _res: Response, @Body payload: InstanceAiGatewayCapabilitiesDto) {
|
||||
const key = this.getGatewayKeyHeader(req);
|
||||
const userId = this.validateGatewayApiKey(key);
|
||||
await this.assertGatewayEnabled(userId);
|
||||
|
||||
const parsed = instanceAiGatewayCapabilitiesSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
throw new BadRequestError(parsed.error.message);
|
||||
}
|
||||
this.instanceAiService.initGateway(userId, parsed.data);
|
||||
this.instanceAiService.initGateway(userId, payload);
|
||||
|
||||
this.push.sendToUsers(
|
||||
{
|
||||
type: 'instanceAiGatewayStateChanged',
|
||||
data: {
|
||||
connected: true,
|
||||
directory: parsed.data.rootPath,
|
||||
hostIdentifier: parsed.data.hostIdentifier ?? null,
|
||||
toolCategories: parsed.data.toolCategories ?? [],
|
||||
directory: payload.rootPath,
|
||||
hostIdentifier: payload.hostIdentifier ?? null,
|
||||
toolCategories: payload.toolCategories ?? [],
|
||||
},
|
||||
},
|
||||
[userId],
|
||||
|
|
@ -674,18 +702,19 @@ export class InstanceAiController {
|
|||
}
|
||||
|
||||
@Post('/gateway/response/:requestId', { skipAuth: true })
|
||||
gatewayResponse(req: Request, _res: Response, @Param('requestId') requestId: string) {
|
||||
gatewayResponse(
|
||||
req: Request,
|
||||
_res: Response,
|
||||
@Param('requestId') requestId: string,
|
||||
@Body payload: InstanceAiFilesystemResponseDto,
|
||||
) {
|
||||
const userId = this.validateGatewayApiKey(this.getGatewayKeyHeader(req));
|
||||
|
||||
const parsed = instanceAiFilesystemResponseSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
throw new BadRequestError(parsed.error.message);
|
||||
}
|
||||
const resolved = this.instanceAiService.resolveGatewayRequest(
|
||||
userId,
|
||||
requestId,
|
||||
parsed.data.result,
|
||||
parsed.data.error,
|
||||
payload.result,
|
||||
payload.error,
|
||||
);
|
||||
if (!resolved) {
|
||||
throw new NotFoundError('Gateway request not found or already resolved');
|
||||
|
|
@ -696,6 +725,7 @@ export class InstanceAiController {
|
|||
@Get('/gateway/status')
|
||||
@GlobalScope('instanceAi:gateway')
|
||||
async gatewayStatus(req: AuthenticatedRequest) {
|
||||
await this.assertGatewayEnabled(req.user.id);
|
||||
return this.instanceAiService.getGatewayStatus(req.user.id);
|
||||
}
|
||||
|
||||
|
|
@ -719,6 +749,13 @@ export class InstanceAiController {
|
|||
}
|
||||
}
|
||||
|
||||
/** Throw if the local gateway is disabled globally or for this user. */
|
||||
private async assertGatewayEnabled(userId: string): Promise<void> {
|
||||
if (await this.settingsService.isLocalGatewayDisabledForUser(userId)) {
|
||||
throw new ForbiddenError('Local gateway is disabled');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely extract and validate the x-gateway-key header value.
|
||||
* Headers can be string | string[] | undefined — take only the first value
|
||||
|
|
|
|||
|
|
@ -38,8 +38,10 @@ export class InstanceAiModule implements ModuleInterface {
|
|||
}
|
||||
|
||||
async settings() {
|
||||
const { GlobalConfig } = await import('@n8n/config');
|
||||
const { InstanceAiService } = await import('./instance-ai.service');
|
||||
const { InstanceAiSettingsService } = await import('./instance-ai-settings.service');
|
||||
const globalConfig = Container.get(GlobalConfig);
|
||||
const service = Container.get(InstanceAiService);
|
||||
const settingsService = Container.get(InstanceAiSettingsService);
|
||||
const enabled = service.isEnabled();
|
||||
|
|
@ -50,6 +52,7 @@ export class InstanceAiModule implements ModuleInterface {
|
|||
localGatewayDisabled,
|
||||
proxyEnabled: service.isProxyEnabled(),
|
||||
optinModalDismissed,
|
||||
cloudManaged: globalConfig.deployment.type === 'cloud',
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -958,6 +958,13 @@ export class InstanceAiService {
|
|||
this.gatewayRegistry.disconnectGateway(userId);
|
||||
}
|
||||
|
||||
/** Disconnect all connected gateways and return the user IDs that were connected. */
|
||||
disconnectAllGateways(): string[] {
|
||||
const connectedUserIds = this.gatewayRegistry.getConnectedUserIds();
|
||||
this.gatewayRegistry.disconnectAll();
|
||||
return connectedUserIds;
|
||||
}
|
||||
|
||||
isLocalGatewayDisabled(): boolean {
|
||||
return this.settingsService.isLocalGatewayDisabled();
|
||||
}
|
||||
|
|
@ -1180,7 +1187,7 @@ export class InstanceAiService {
|
|||
messageGroupId?: string,
|
||||
pushRef?: string,
|
||||
) {
|
||||
const localGatewayDisabled = this.settingsService.isLocalGatewayDisabled();
|
||||
const localGatewayDisabled = await this.settingsService.isLocalGatewayDisabledForUser(user.id);
|
||||
const userGateway = this.gatewayRegistry.findGateway(user.id);
|
||||
|
||||
// When the proxy is enabled, create a single ProxyTokenManager and
|
||||
|
|
|
|||
|
|
@ -140,7 +140,7 @@ onMounted(() => {
|
|||
.refreshModuleSettings()
|
||||
.catch(() => {})
|
||||
.then(() => {
|
||||
if (!settingsStore.isLocalGatewayDisabled) {
|
||||
if (!settingsStore.isLocalGatewayDisabled && !settingsStore.isInstanceAiDisabled) {
|
||||
settingsStore.startDaemonProbing();
|
||||
settingsStore.startGatewayPushListener();
|
||||
settingsStore.pollGatewayStatus();
|
||||
|
|
@ -150,7 +150,7 @@ onMounted(() => {
|
|||
|
||||
// React to local gateway being toggled in settings without requiring a page reload
|
||||
watch(
|
||||
() => settingsStore.isLocalGatewayDisabled,
|
||||
() => settingsStore.isLocalGatewayDisabled || settingsStore.isInstanceAiDisabled,
|
||||
(disabled) => {
|
||||
if (disabled) {
|
||||
settingsStore.stopDaemonProbing();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,123 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { setActivePinia } from 'pinia';
|
||||
import { within } from '@testing-library/vue';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import LocalGatewaySection from '../components/settings/LocalGatewaySection.vue';
|
||||
import { useInstanceAiSettingsStore } from '../instanceAiSettings.store';
|
||||
import { useSettingsStore } from '@/app/stores/settings.store';
|
||||
|
||||
vi.mock('@n8n/i18n', async (importOriginal) => ({
|
||||
...(await importOriginal()),
|
||||
useI18n: () => ({
|
||||
baseText: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../instanceAi.api', () => ({
|
||||
createGatewayLink: vi.fn(),
|
||||
getGatewayStatus: vi.fn().mockResolvedValue({
|
||||
connected: false,
|
||||
directory: null,
|
||||
hostIdentifier: null,
|
||||
toolCategories: [],
|
||||
}),
|
||||
}));
|
||||
|
||||
const renderComponent = createComponentRenderer(LocalGatewaySection);
|
||||
|
||||
describe('LocalGatewaySection', () => {
|
||||
let store: ReturnType<typeof useInstanceAiSettingsStore>;
|
||||
let settingsStore: ReturnType<typeof useSettingsStore>;
|
||||
|
||||
beforeEach(() => {
|
||||
const pinia = createTestingPinia({ stubActions: false });
|
||||
setActivePinia(pinia);
|
||||
store = useInstanceAiSettingsStore();
|
||||
settingsStore = useSettingsStore();
|
||||
settingsStore.moduleSettings = {
|
||||
'instance-ai': {
|
||||
enabled: true,
|
||||
localGatewayDisabled: false,
|
||||
proxyEnabled: false,
|
||||
optinModalDismissed: true,
|
||||
cloudManaged: false,
|
||||
},
|
||||
};
|
||||
store.$patch({ preferences: { localGatewayDisabled: false } });
|
||||
});
|
||||
|
||||
it('shows heading and description', () => {
|
||||
const { getByText } = renderComponent();
|
||||
expect(getByText('instanceAi.filesystem.label')).toBeVisible();
|
||||
expect(getByText('instanceAi.filesystem.description')).toBeVisible();
|
||||
});
|
||||
|
||||
it('shows user toggle switch', () => {
|
||||
const { container } = renderComponent();
|
||||
const switchEl = container.querySelector('.el-switch');
|
||||
expect(switchEl).toBeTruthy();
|
||||
});
|
||||
|
||||
it('disables user toggle when admin has disabled gateway', () => {
|
||||
settingsStore.moduleSettings = {
|
||||
'instance-ai': {
|
||||
enabled: true,
|
||||
localGatewayDisabled: true,
|
||||
proxyEnabled: false,
|
||||
optinModalDismissed: true,
|
||||
cloudManaged: false,
|
||||
},
|
||||
};
|
||||
const { container } = renderComponent();
|
||||
const switchEl = container.querySelector('.el-switch');
|
||||
expect(switchEl?.classList.contains('is-disabled')).toBe(true);
|
||||
});
|
||||
|
||||
it('shows warning when admin has disabled gateway', () => {
|
||||
settingsStore.moduleSettings = {
|
||||
'instance-ai': {
|
||||
enabled: true,
|
||||
localGatewayDisabled: true,
|
||||
proxyEnabled: false,
|
||||
optinModalDismissed: true,
|
||||
cloudManaged: false,
|
||||
},
|
||||
};
|
||||
const { getByText } = renderComponent();
|
||||
expect(getByText('settings.n8nAgent.computerUse.disabled.warning')).toBeVisible();
|
||||
});
|
||||
|
||||
it('hides warning when admin has not disabled gateway', () => {
|
||||
const { queryByText } = renderComponent();
|
||||
expect(queryByText('settings.n8nAgent.computerUse.disabled.warning')).toBeNull();
|
||||
});
|
||||
|
||||
it('calls save immediately when user toggle is clicked', async () => {
|
||||
const saveSpy = vi.spyOn(store, 'save').mockResolvedValue();
|
||||
const { container } = renderComponent();
|
||||
|
||||
const switchRow = container.querySelector('[class*="switchRow"]')!;
|
||||
const switchEl = within(switchRow as HTMLElement).getByRole('switch');
|
||||
await userEvent.click(switchEl);
|
||||
|
||||
expect(store.preferencesDraft.localGatewayDisabled).toBe(true);
|
||||
expect(saveSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows setup command when gateway is not connected and user toggle is on', () => {
|
||||
store.$patch({ preferences: { localGatewayDisabled: false } });
|
||||
const { getByText } = renderComponent();
|
||||
expect(getByText('instanceAi.filesystem.setupCommand')).toBeVisible();
|
||||
});
|
||||
|
||||
it('hides setup content when user toggle is off', () => {
|
||||
store.$patch({
|
||||
preferences: { localGatewayDisabled: true },
|
||||
preferencesDraft: { localGatewayDisabled: true },
|
||||
});
|
||||
const { queryByText } = renderComponent();
|
||||
expect(queryByText('instanceAi.filesystem.setupCommand')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,242 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { setActivePinia } from 'pinia';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import SettingsInstanceAiView from '../views/SettingsInstanceAiView.vue';
|
||||
import { useInstanceAiSettingsStore } from '../instanceAiSettings.store';
|
||||
import { useSettingsStore } from '@/app/stores/settings.store';
|
||||
import type { FrontendModuleSettings } from '@n8n/api-types';
|
||||
|
||||
vi.mock('@n8n/i18n', async (importOriginal) => ({
|
||||
...(await importOriginal()),
|
||||
useI18n: () => ({
|
||||
baseText: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/app/composables/useDocumentTitle', () => ({
|
||||
useDocumentTitle: () => ({ set: vi.fn() }),
|
||||
}));
|
||||
|
||||
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().mockResolvedValue({
|
||||
credentialId: null,
|
||||
credentialType: null,
|
||||
credentialName: null,
|
||||
modelName: 'gpt-4',
|
||||
localGatewayDisabled: false,
|
||||
}),
|
||||
updatePreferences: vi.fn(),
|
||||
fetchModelCredentials: vi.fn().mockResolvedValue([]),
|
||||
fetchServiceCredentials: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
vi.mock('../instanceAi.api', () => ({
|
||||
createGatewayLink: vi.fn(),
|
||||
getGatewayStatus: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/app/utils/rbac/permissions', () => ({
|
||||
hasPermission: vi.fn().mockReturnValue(true),
|
||||
}));
|
||||
|
||||
function makeStub(name: string) {
|
||||
return { template: `<div data-test-stub="${name}" />` };
|
||||
}
|
||||
|
||||
const renderComponent = createComponentRenderer(SettingsInstanceAiView, {
|
||||
global: {
|
||||
stubs: {
|
||||
ModelSection: makeStub('ModelSection'),
|
||||
LocalGatewaySection: makeStub('LocalGatewaySection'),
|
||||
SandboxSection: makeStub('SandboxSection'),
|
||||
MemorySection: makeStub('MemorySection'),
|
||||
SearchSection: makeStub('SearchSection'),
|
||||
AdvancedSection: makeStub('AdvancedSection'),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
function setModuleSettings(
|
||||
settingsStore: ReturnType<typeof useSettingsStore>,
|
||||
instanceAi: FrontendModuleSettings['instance-ai'],
|
||||
) {
|
||||
settingsStore.moduleSettings = { 'instance-ai': instanceAi };
|
||||
}
|
||||
|
||||
const defaultModuleSettings: NonNullable<FrontendModuleSettings['instance-ai']> = {
|
||||
enabled: true,
|
||||
localGatewayDisabled: false,
|
||||
proxyEnabled: false,
|
||||
optinModalDismissed: true,
|
||||
cloudManaged: false,
|
||||
};
|
||||
|
||||
describe('SettingsInstanceAiView', () => {
|
||||
let store: ReturnType<typeof useInstanceAiSettingsStore>;
|
||||
let settingsStore: ReturnType<typeof useSettingsStore>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
const pinia = createTestingPinia({ stubActions: false });
|
||||
setActivePinia(pinia);
|
||||
store = useInstanceAiSettingsStore();
|
||||
settingsStore = useSettingsStore();
|
||||
setModuleSettings(settingsStore, { ...defaultModuleSettings });
|
||||
store.$patch({
|
||||
settings: {
|
||||
enabled: true,
|
||||
lastMessages: 20,
|
||||
embedderModel: '',
|
||||
semanticRecallTopK: 5,
|
||||
subAgentMaxSteps: 10,
|
||||
browserMcp: false,
|
||||
permissions: {},
|
||||
mcpServers: '',
|
||||
sandboxEnabled: false,
|
||||
sandboxProvider: '',
|
||||
sandboxImage: '',
|
||||
sandboxTimeout: 60,
|
||||
daytonaCredentialId: null,
|
||||
n8nSandboxCredentialId: null,
|
||||
searchCredentialId: null,
|
||||
localGatewayDisabled: false,
|
||||
optinModalDismissed: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
function queryStub(container: Element, name: string) {
|
||||
return container.querySelector(`[data-test-stub="${name}"]`);
|
||||
}
|
||||
|
||||
describe('section visibility — self-hosted (no proxy, no cloud)', () => {
|
||||
it('shows Model section', () => {
|
||||
const { container } = renderComponent();
|
||||
expect(queryStub(container, 'ModelSection')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('shows Sandbox section', () => {
|
||||
const { container } = renderComponent();
|
||||
expect(queryStub(container, 'SandboxSection')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('shows Memory section', () => {
|
||||
const { container } = renderComponent();
|
||||
expect(queryStub(container, 'MemorySection')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('shows Search section', () => {
|
||||
const { container } = renderComponent();
|
||||
expect(queryStub(container, 'SearchSection')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('shows Advanced section', () => {
|
||||
const { container } = renderComponent();
|
||||
expect(queryStub(container, 'AdvancedSection')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('section visibility — proxy enabled (non-cloud)', () => {
|
||||
beforeEach(() => {
|
||||
setModuleSettings(settingsStore, { ...defaultModuleSettings, proxyEnabled: true });
|
||||
});
|
||||
|
||||
it('hides Model section', () => {
|
||||
const { container } = renderComponent();
|
||||
expect(queryStub(container, 'ModelSection')).toBeNull();
|
||||
});
|
||||
|
||||
it('hides Sandbox section', () => {
|
||||
const { container } = renderComponent();
|
||||
expect(queryStub(container, 'SandboxSection')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows Memory section', () => {
|
||||
const { container } = renderComponent();
|
||||
expect(queryStub(container, 'MemorySection')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('hides Search section', () => {
|
||||
const { container } = renderComponent();
|
||||
expect(queryStub(container, 'SearchSection')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows Advanced section', () => {
|
||||
const { container } = renderComponent();
|
||||
expect(queryStub(container, 'AdvancedSection')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('section visibility — cloud managed', () => {
|
||||
beforeEach(() => {
|
||||
setModuleSettings(settingsStore, {
|
||||
...defaultModuleSettings,
|
||||
proxyEnabled: true,
|
||||
cloudManaged: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('hides Model section', () => {
|
||||
const { container } = renderComponent();
|
||||
expect(queryStub(container, 'ModelSection')).toBeNull();
|
||||
});
|
||||
|
||||
it('hides Sandbox section', () => {
|
||||
const { container } = renderComponent();
|
||||
expect(queryStub(container, 'SandboxSection')).toBeNull();
|
||||
});
|
||||
|
||||
it('hides Memory section', () => {
|
||||
const { container } = renderComponent();
|
||||
expect(queryStub(container, 'MemorySection')).toBeNull();
|
||||
});
|
||||
|
||||
it('hides Search section', () => {
|
||||
const { container } = renderComponent();
|
||||
expect(queryStub(container, 'SearchSection')).toBeNull();
|
||||
});
|
||||
|
||||
it('hides Advanced section', () => {
|
||||
const { container } = renderComponent();
|
||||
expect(queryStub(container, 'AdvancedSection')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows Permissions section', () => {
|
||||
const { getByText } = renderComponent();
|
||||
expect(getByText('settings.n8nAgent.permissions.title')).toBeVisible();
|
||||
});
|
||||
|
||||
it('shows Enable toggle', () => {
|
||||
const { getByTestId } = renderComponent();
|
||||
expect(getByTestId('n8n-agent-enable-toggle')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isEnabled fallback for non-admin users', () => {
|
||||
it('falls back to moduleSettings when store.settings is null', () => {
|
||||
store.$patch({ settings: null });
|
||||
setModuleSettings(settingsStore, { ...defaultModuleSettings, enabled: true });
|
||||
|
||||
const { getByText } = renderComponent();
|
||||
// When enabled, the local gateway section should render
|
||||
expect(getByText('settings.n8nAgent.permissions.title')).toBeVisible();
|
||||
});
|
||||
|
||||
it('hides content when disabled via moduleSettings fallback', () => {
|
||||
store.$patch({ settings: null });
|
||||
setModuleSettings(settingsStore, { ...defaultModuleSettings, enabled: false });
|
||||
|
||||
const { queryByText } = renderComponent();
|
||||
expect(queryByText('settings.n8nAgent.permissions.title')).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,303 @@
|
|||
import { setActivePinia, createPinia } from 'pinia';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { useInstanceAiSettingsStore } from '../instanceAiSettings.store';
|
||||
import { useSettingsStore } from '@/app/stores/settings.store';
|
||||
import type { FrontendModuleSettings } from '@n8n/api-types';
|
||||
|
||||
vi.mock('@n8n/stores/useRootStore', () => ({
|
||||
useRootStore: vi.fn().mockReturnValue({
|
||||
restApiContext: { baseUrl: 'http://localhost:5678/rest' },
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/app/composables/useToast', () => ({
|
||||
useToast: vi.fn().mockReturnValue({
|
||||
showMessage: vi.fn(),
|
||||
showError: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/app/stores/pushConnection.store', () => ({
|
||||
usePushConnectionStore: vi.fn().mockReturnValue({
|
||||
addEventListener: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
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([]);
|
||||
|
||||
vi.mock('../instanceAi.settings.api', () => ({
|
||||
fetchSettings: (...args: unknown[]) => mockFetchSettings(...args),
|
||||
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),
|
||||
}));
|
||||
|
||||
vi.mock('../instanceAi.api', () => ({
|
||||
createGatewayLink: vi.fn(),
|
||||
getGatewayStatus: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/app/utils/rbac/permissions', () => ({
|
||||
hasPermission: vi.fn().mockReturnValue(false),
|
||||
}));
|
||||
|
||||
function setModuleSettings(
|
||||
settingsStore: ReturnType<typeof useSettingsStore>,
|
||||
instanceAi: FrontendModuleSettings['instance-ai'],
|
||||
) {
|
||||
settingsStore.moduleSettings = { 'instance-ai': instanceAi };
|
||||
}
|
||||
|
||||
describe('useInstanceAiSettingsStore', () => {
|
||||
let store: ReturnType<typeof useInstanceAiSettingsStore>;
|
||||
let settingsStore: ReturnType<typeof useSettingsStore>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
setActivePinia(createPinia());
|
||||
store = useInstanceAiSettingsStore();
|
||||
settingsStore = useSettingsStore();
|
||||
});
|
||||
|
||||
describe('isInstanceAiDisabled', () => {
|
||||
it('returns true when module settings has enabled=false', () => {
|
||||
setModuleSettings(settingsStore, {
|
||||
enabled: false,
|
||||
localGatewayDisabled: false,
|
||||
proxyEnabled: false,
|
||||
optinModalDismissed: false,
|
||||
cloudManaged: false,
|
||||
});
|
||||
expect(store.isInstanceAiDisabled).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when module settings has enabled=true', () => {
|
||||
setModuleSettings(settingsStore, {
|
||||
enabled: true,
|
||||
localGatewayDisabled: false,
|
||||
proxyEnabled: false,
|
||||
optinModalDismissed: false,
|
||||
cloudManaged: false,
|
||||
});
|
||||
expect(store.isInstanceAiDisabled).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true when module settings is undefined', () => {
|
||||
settingsStore.moduleSettings = {};
|
||||
expect(store.isInstanceAiDisabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isLocalGatewayDisabledByAdmin', () => {
|
||||
it('defaults to true when module settings have not loaded yet', () => {
|
||||
settingsStore.moduleSettings = {};
|
||||
expect(store.isLocalGatewayDisabledByAdmin).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true when admin flag is set', () => {
|
||||
setModuleSettings(settingsStore, {
|
||||
enabled: true,
|
||||
localGatewayDisabled: true,
|
||||
proxyEnabled: false,
|
||||
optinModalDismissed: false,
|
||||
cloudManaged: false,
|
||||
});
|
||||
expect(store.isLocalGatewayDisabledByAdmin).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when admin flag is not set even if user preference is', () => {
|
||||
setModuleSettings(settingsStore, {
|
||||
enabled: true,
|
||||
localGatewayDisabled: false,
|
||||
proxyEnabled: false,
|
||||
optinModalDismissed: false,
|
||||
cloudManaged: false,
|
||||
});
|
||||
store.$patch({ preferences: { localGatewayDisabled: true } });
|
||||
expect(store.isLocalGatewayDisabledByAdmin).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isLocalGatewayDisabled', () => {
|
||||
it('returns true when admin flag is set', () => {
|
||||
setModuleSettings(settingsStore, {
|
||||
enabled: true,
|
||||
localGatewayDisabled: true,
|
||||
proxyEnabled: false,
|
||||
optinModalDismissed: false,
|
||||
cloudManaged: false,
|
||||
});
|
||||
expect(store.isLocalGatewayDisabled).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true when user preference is set', () => {
|
||||
setModuleSettings(settingsStore, {
|
||||
enabled: true,
|
||||
localGatewayDisabled: false,
|
||||
proxyEnabled: false,
|
||||
optinModalDismissed: false,
|
||||
cloudManaged: false,
|
||||
});
|
||||
store.$patch({ preferences: { localGatewayDisabled: true } });
|
||||
expect(store.isLocalGatewayDisabled).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true when both admin flag and user preference are set', () => {
|
||||
setModuleSettings(settingsStore, {
|
||||
enabled: true,
|
||||
localGatewayDisabled: true,
|
||||
proxyEnabled: false,
|
||||
optinModalDismissed: false,
|
||||
cloudManaged: false,
|
||||
});
|
||||
store.$patch({ preferences: { localGatewayDisabled: true } });
|
||||
expect(store.isLocalGatewayDisabled).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when neither admin flag nor user preference is set', () => {
|
||||
setModuleSettings(settingsStore, {
|
||||
enabled: true,
|
||||
localGatewayDisabled: false,
|
||||
proxyEnabled: false,
|
||||
optinModalDismissed: false,
|
||||
cloudManaged: false,
|
||||
});
|
||||
store.$patch({ preferences: { localGatewayDisabled: false } });
|
||||
expect(store.isLocalGatewayDisabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isProxyEnabled', () => {
|
||||
it('returns true when proxyEnabled is true in module settings', () => {
|
||||
setModuleSettings(settingsStore, {
|
||||
enabled: true,
|
||||
localGatewayDisabled: false,
|
||||
proxyEnabled: true,
|
||||
optinModalDismissed: false,
|
||||
cloudManaged: false,
|
||||
});
|
||||
expect(store.isProxyEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when proxyEnabled is false', () => {
|
||||
setModuleSettings(settingsStore, {
|
||||
enabled: true,
|
||||
localGatewayDisabled: false,
|
||||
proxyEnabled: false,
|
||||
optinModalDismissed: false,
|
||||
cloudManaged: false,
|
||||
});
|
||||
expect(store.isProxyEnabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isCloudManaged', () => {
|
||||
it('returns true when cloudManaged is true in module settings', () => {
|
||||
setModuleSettings(settingsStore, {
|
||||
enabled: true,
|
||||
localGatewayDisabled: false,
|
||||
proxyEnabled: false,
|
||||
optinModalDismissed: false,
|
||||
cloudManaged: true,
|
||||
});
|
||||
expect(store.isCloudManaged).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when cloudManaged is false', () => {
|
||||
setModuleSettings(settingsStore, {
|
||||
enabled: true,
|
||||
localGatewayDisabled: false,
|
||||
proxyEnabled: false,
|
||||
optinModalDismissed: false,
|
||||
cloudManaged: false,
|
||||
});
|
||||
expect(store.isCloudManaged).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('refreshModuleSettings', () => {
|
||||
it('fetches preferences when they are not loaded yet', async () => {
|
||||
const prefsResponse = {
|
||||
credentialId: null,
|
||||
credentialType: null,
|
||||
credentialName: null,
|
||||
modelName: 'gpt-4',
|
||||
localGatewayDisabled: false,
|
||||
};
|
||||
mockFetchPreferences.mockResolvedValue(prefsResponse);
|
||||
settingsStore.getModuleSettings = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
await store.refreshModuleSettings();
|
||||
|
||||
expect(mockFetchPreferences).toHaveBeenCalled();
|
||||
expect(store.preferences).toEqual(prefsResponse);
|
||||
});
|
||||
|
||||
it('does not fetch preferences when they are already loaded', async () => {
|
||||
store.$patch({
|
||||
preferences: {
|
||||
credentialId: null,
|
||||
credentialType: null,
|
||||
credentialName: null,
|
||||
modelName: 'gpt-4',
|
||||
localGatewayDisabled: false,
|
||||
},
|
||||
});
|
||||
settingsStore.getModuleSettings = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
await store.refreshModuleSettings();
|
||||
|
||||
expect(mockFetchPreferences).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('syncInstanceAiFlagIntoGlobalModuleSettings', () => {
|
||||
it('preserves cloudManaged when syncing admin settings', async () => {
|
||||
setModuleSettings(settingsStore, {
|
||||
enabled: false,
|
||||
localGatewayDisabled: false,
|
||||
proxyEnabled: true,
|
||||
optinModalDismissed: false,
|
||||
cloudManaged: true,
|
||||
});
|
||||
|
||||
const adminResponse = {
|
||||
enabled: true,
|
||||
lastMessages: 20,
|
||||
embedderModel: '',
|
||||
semanticRecallTopK: 5,
|
||||
subAgentMaxSteps: 10,
|
||||
browserMcp: false,
|
||||
permissions: {},
|
||||
mcpServers: '',
|
||||
sandboxEnabled: false,
|
||||
sandboxProvider: '',
|
||||
sandboxImage: '',
|
||||
sandboxTimeout: 60,
|
||||
daytonaCredentialId: null,
|
||||
n8nSandboxCredentialId: null,
|
||||
searchCredentialId: null,
|
||||
localGatewayDisabled: false,
|
||||
optinModalDismissed: true,
|
||||
};
|
||||
|
||||
mockUpdateSettings.mockResolvedValue(adminResponse);
|
||||
settingsStore.getModuleSettings = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
// persistEnabled triggers syncInstanceAiFlagIntoGlobalModuleSettings
|
||||
await store.persistEnabled(true);
|
||||
|
||||
const ms = settingsStore.moduleSettings['instance-ai'];
|
||||
expect(ms?.cloudManaged).toBe(true);
|
||||
expect(ms?.proxyEnabled).toBe(true);
|
||||
expect(ms?.enabled).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -257,7 +257,7 @@ onUnmounted(() => {
|
|||
<N8nButton
|
||||
variant="solid"
|
||||
size="large"
|
||||
:disabled="!hasChosen"
|
||||
:disabled="!hasChosen || isSaving"
|
||||
:label="
|
||||
isEnabled
|
||||
? i18n.baseText('instanceAi.welcomeModal.enable')
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<script lang="ts" setup>
|
||||
import { computed, ref, onMounted } from 'vue';
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue';
|
||||
import { N8nHeading, N8nIcon, N8nIconButton, N8nText, N8nTooltip } from '@n8n/design-system';
|
||||
import type { IconName } from '@n8n/design-system';
|
||||
import { ElSwitch } from 'element-plus';
|
||||
|
|
@ -16,6 +16,15 @@ const isLocalGatewayDisabled = computed(() => {
|
|||
return store.preferences?.localGatewayDisabled ?? false;
|
||||
});
|
||||
|
||||
async function handleUserToggle(value: boolean | string | number) {
|
||||
const enabling = Boolean(value);
|
||||
store.setPreferenceField('localGatewayDisabled', !enabling);
|
||||
await store.save();
|
||||
if (enabling && !store.setupCommand) {
|
||||
void store.fetchSetupCommand();
|
||||
}
|
||||
}
|
||||
|
||||
const copied = ref(false);
|
||||
const displayCommand = computed(() => store.setupCommand ?? 'npx @n8n/computer-use');
|
||||
|
||||
|
|
@ -83,10 +92,47 @@ const displayCategories = computed(() => {
|
|||
return result.sort((a, b) => Number(b.enabled) - Number(a.enabled));
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
if (!store.isGatewayConnected) {
|
||||
void store.fetchSetupCommand();
|
||||
}
|
||||
const shouldShowSetup = computed(
|
||||
() =>
|
||||
!store.isGatewayConnected &&
|
||||
!store.isLocalGatewayDisabledByAdmin &&
|
||||
!isLocalGatewayDisabled.value,
|
||||
);
|
||||
|
||||
watch(
|
||||
shouldShowSetup,
|
||||
(show) => {
|
||||
if (show && !store.setupCommand) {
|
||||
void store.fetchSetupCommand();
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
// Keep the connection status live on the settings page. Without these, landing
|
||||
// directly on /settings/n8n-agent leaves `isGatewayConnected` at its default
|
||||
// `false` because neither the poll nor the push listener is ever started here.
|
||||
const shouldMonitorConnection = computed(
|
||||
() => !store.isLocalGatewayDisabledByAdmin && !isLocalGatewayDisabled.value,
|
||||
);
|
||||
|
||||
watch(
|
||||
shouldMonitorConnection,
|
||||
(monitor) => {
|
||||
if (monitor) {
|
||||
store.startGatewayPushListener();
|
||||
store.pollGatewayStatus();
|
||||
} else {
|
||||
store.stopGatewayPushListener();
|
||||
store.stopGatewayPolling();
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
store.stopGatewayPushListener();
|
||||
store.stopGatewayPolling();
|
||||
});
|
||||
</script>
|
||||
|
||||
|
|
@ -102,20 +148,20 @@ onMounted(() => {
|
|||
</N8nText>
|
||||
</div>
|
||||
<ElSwitch
|
||||
:model-value="!isLocalGatewayDisabled"
|
||||
:disabled="store.isLocalGatewayDisabled"
|
||||
@update:model-value="store.setPreferenceField('localGatewayDisabled', !$event)"
|
||||
:model-value="!store.isLocalGatewayDisabledByAdmin && !isLocalGatewayDisabled"
|
||||
:disabled="store.isLocalGatewayDisabledByAdmin"
|
||||
@update:model-value="handleUserToggle"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="store.isLocalGatewayDisabled" :class="$style.warningRow">
|
||||
<div v-if="store.isLocalGatewayDisabledByAdmin" :class="$style.warningRow">
|
||||
<N8nIcon icon="triangle-alert" size="small" />
|
||||
<N8nText size="small" color="text-light">
|
||||
{{ i18n.baseText('settings.n8nAgent.computerUse.disabled.warning') }}
|
||||
</N8nText>
|
||||
</div>
|
||||
|
||||
<template v-if="!isLocalGatewayDisabled && !store.isLocalGatewayDisabled">
|
||||
<template v-if="!isLocalGatewayDisabled && !store.isLocalGatewayDisabledByAdmin">
|
||||
<!-- Gateway connected -->
|
||||
<div v-if="store.isGatewayConnected" :class="$style.connectedBlock">
|
||||
<div :class="$style.statusRow">
|
||||
|
|
@ -197,11 +243,6 @@ onMounted(() => {
|
|||
padding: var(--spacing--4xs) 0;
|
||||
}
|
||||
|
||||
.switchLabel {
|
||||
font-size: var(--font-size--2xs);
|
||||
color: var(--color--text--tint-1);
|
||||
}
|
||||
|
||||
.warningRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
|
|||
|
|
@ -51,12 +51,21 @@ export const useInstanceAiSettingsStore = defineStore('instanceAiSettings', () =
|
|||
const gatewayToolCategories = ref<ToolCategory[]>([]);
|
||||
const isGatewayConnected = computed(() => gatewayConnected.value);
|
||||
const activeDirectory = computed(() => gatewayDirectory.value);
|
||||
const isInstanceAiDisabled = computed(
|
||||
() => settingsStore.moduleSettings?.['instance-ai']?.enabled !== true,
|
||||
);
|
||||
const isLocalGatewayDisabledByAdmin = computed(
|
||||
() => settingsStore.moduleSettings?.['instance-ai']?.localGatewayDisabled !== false,
|
||||
);
|
||||
const isLocalGatewayDisabled = computed(
|
||||
() => settingsStore.moduleSettings?.['instance-ai']?.localGatewayDisabled === true,
|
||||
() => isLocalGatewayDisabledByAdmin.value || preferences.value?.localGatewayDisabled === true,
|
||||
);
|
||||
const isProxyEnabled = computed(
|
||||
() => settingsStore.moduleSettings?.['instance-ai']?.proxyEnabled === true,
|
||||
);
|
||||
const isCloudManaged = computed(
|
||||
() => settingsStore.moduleSettings?.['instance-ai']?.cloudManaged === true,
|
||||
);
|
||||
|
||||
const isDirty = computed(() => {
|
||||
if (!settings.value && !preferences.value) return false;
|
||||
|
|
@ -70,9 +79,10 @@ export const useInstanceAiSettingsStore = defineStore('instanceAiSettings', () =
|
|||
const prev = ms['instance-ai'];
|
||||
const merged: NonNullable<FrontendModuleSettings['instance-ai']> = {
|
||||
enabled: adminRes.enabled,
|
||||
localGatewayDisabled: prev?.localGatewayDisabled ?? false,
|
||||
localGatewayDisabled: adminRes.localGatewayDisabled ?? prev?.localGatewayDisabled ?? false,
|
||||
proxyEnabled: prev?.proxyEnabled ?? false,
|
||||
optinModalDismissed: adminRes.optinModalDismissed,
|
||||
cloudManaged: prev?.cloudManaged ?? false,
|
||||
};
|
||||
settingsStore.moduleSettings = {
|
||||
...ms,
|
||||
|
|
@ -354,6 +364,7 @@ export const useInstanceAiSettingsStore = defineStore('instanceAiSettings', () =
|
|||
}
|
||||
|
||||
async function fetchSetupCommand(): Promise<void> {
|
||||
if (isLocalGatewayDisabled.value) return;
|
||||
try {
|
||||
const result = await createGatewayLink(rootStore.restApiContext);
|
||||
setupCommand.value = result.command;
|
||||
|
|
@ -377,7 +388,15 @@ export const useInstanceAiSettingsStore = defineStore('instanceAiSettings', () =
|
|||
}
|
||||
|
||||
async function refreshModuleSettings(): Promise<void> {
|
||||
await settingsStore.getModuleSettings();
|
||||
const promises: Array<Promise<unknown>> = [settingsStore.getModuleSettings()];
|
||||
if (!preferences.value) {
|
||||
promises.push(
|
||||
fetchPreferences(rootStore.restApiContext).then((p) => {
|
||||
preferences.value = p;
|
||||
}),
|
||||
);
|
||||
}
|
||||
await Promise.all(promises);
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
@ -409,8 +428,11 @@ export const useInstanceAiSettingsStore = defineStore('instanceAiSettings', () =
|
|||
gatewayHostIdentifier,
|
||||
gatewayToolCategories,
|
||||
activeDirectory,
|
||||
isInstanceAiDisabled,
|
||||
isLocalGatewayDisabled,
|
||||
isLocalGatewayDisabledByAdmin,
|
||||
isProxyEnabled,
|
||||
isCloudManaged,
|
||||
pollGatewayStatus,
|
||||
stopGatewayPolling,
|
||||
startDaemonProbing,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { useI18n } from '@n8n/i18n';
|
|||
import { useDocumentTitle } from '@/app/composables/useDocumentTitle';
|
||||
import type { InstanceAiPermissions, InstanceAiPermissionMode } from '@n8n/api-types';
|
||||
import type { BaseTextKey } from '@n8n/i18n';
|
||||
import { useSettingsStore } from '@/app/stores/settings.store';
|
||||
import { useInstanceAiSettingsStore } from '../instanceAiSettings.store';
|
||||
import ModelSection from '../components/settings/ModelSection.vue';
|
||||
import LocalGatewaySection from '../components/settings/LocalGatewaySection.vue';
|
||||
|
|
@ -16,6 +17,7 @@ import AdvancedSection from '../components/settings/AdvancedSection.vue';
|
|||
|
||||
const i18n = useI18n();
|
||||
const documentTitle = useDocumentTitle();
|
||||
const settingsStore = useSettingsStore();
|
||||
const store = useInstanceAiSettingsStore();
|
||||
|
||||
const isAdmin = computed(() => store.canManage);
|
||||
|
|
@ -46,7 +48,9 @@ const permissionKeys: Array<{ key: keyof InstanceAiPermissions; labelKey: BaseTe
|
|||
},
|
||||
];
|
||||
|
||||
const isEnabled = computed(() => store.settings?.enabled ?? false);
|
||||
const isEnabled = computed(
|
||||
() => store.settings?.enabled ?? settingsStore.moduleSettings?.['instance-ai']?.enabled ?? false,
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
documentTitle.set(i18n.baseText('settings.n8nAgent'));
|
||||
|
|
@ -87,26 +91,29 @@ function handlePermissionChange(key: keyof InstanceAiPermissions, value: Instanc
|
|||
<template v-else>
|
||||
<template v-if="isAdmin">
|
||||
<div :class="$style.card">
|
||||
<div :class="$style.settingsRow">
|
||||
<div :class="$style.settingsRowLeft">
|
||||
<span :class="$style.settingsRowLabel">
|
||||
<div :class="$style.sectionBlock">
|
||||
<div :class="$style.enableSection">
|
||||
<N8nHeading tag="h2" size="small">
|
||||
{{ i18n.baseText('settings.n8nAgent.enable.label') }}
|
||||
</span>
|
||||
<span :class="$style.settingsRowDescription">
|
||||
{{ i18n.baseText('settings.n8nAgent.enable.description') }}
|
||||
</span>
|
||||
</N8nHeading>
|
||||
<div :class="$style.switchRow">
|
||||
<span :class="$style.switchDescription">
|
||||
{{ i18n.baseText('settings.n8nAgent.enable.description') }}
|
||||
</span>
|
||||
<ElSwitch
|
||||
:model-value="isEnabled"
|
||||
:disabled="store.isSaving"
|
||||
data-test-id="n8n-agent-enable-toggle"
|
||||
@update:model-value="handleEnabledToggle"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ElSwitch
|
||||
:class="$style.toggle"
|
||||
:model-value="isEnabled"
|
||||
:disabled="store.isSaving"
|
||||
data-test-id="n8n-agent-enable-toggle"
|
||||
@update:model-value="handleEnabledToggle"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div :class="$style.card">
|
||||
<template v-if="isEnabled">
|
||||
<div v-if="isAdmin" :class="$style.card">
|
||||
<div :class="$style.settingsRow">
|
||||
<div :class="$style.settingsRowLeft">
|
||||
<span :class="$style.settingsRowLabel">
|
||||
|
|
@ -125,46 +132,13 @@ function handlePermissionChange(key: keyof InstanceAiPermissions, value: Instanc
|
|||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div :class="$style.card">
|
||||
<div :class="$style.sectionBlock">
|
||||
<ModelSection />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div :class="$style.card">
|
||||
<div :class="$style.sectionBlock">
|
||||
<LocalGatewaySection />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-if="isAdmin">
|
||||
<div :class="$style.card">
|
||||
<div :class="$style.sectionBlock">
|
||||
<SandboxSection />
|
||||
<LocalGatewaySection />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div :class="$style.card">
|
||||
<div :class="$style.sectionBlock">
|
||||
<MemorySection />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div :class="$style.card">
|
||||
<div :class="$style.sectionBlock">
|
||||
<SearchSection />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div :class="$style.card">
|
||||
<div :class="$style.sectionBlock">
|
||||
<AdvancedSection />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-if="isEnabled">
|
||||
<template v-if="isAdmin">
|
||||
<div :class="$style.permissionsHeader">
|
||||
<N8nHeading :class="$style.sectionTitle" tag="h3" size="medium">
|
||||
{{ i18n.baseText('settings.n8nAgent.permissions.title') }}
|
||||
|
|
@ -214,6 +188,38 @@ function handlePermissionChange(key: keyof InstanceAiPermissions, value: Instanc
|
|||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="!store.isProxyEnabled" :class="$style.card">
|
||||
<div :class="$style.sectionBlock">
|
||||
<ModelSection />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-if="isAdmin">
|
||||
<div v-if="!store.isProxyEnabled" :class="$style.card">
|
||||
<div :class="$style.sectionBlock">
|
||||
<SandboxSection />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!store.isCloudManaged" :class="$style.card">
|
||||
<div :class="$style.sectionBlock">
|
||||
<MemorySection />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!store.isProxyEnabled" :class="$style.card">
|
||||
<div :class="$style.sectionBlock">
|
||||
<SearchSection />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!store.isCloudManaged" :class="$style.card">
|
||||
<div :class="$style.sectionBlock">
|
||||
<AdvancedSection />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<div v-if="store.isDirty" :class="$style.footer">
|
||||
|
|
@ -273,10 +279,6 @@ function handlePermissionChange(key: keyof InstanceAiPermissions, value: Instanc
|
|||
background: var(--color--background--light-3);
|
||||
}
|
||||
|
||||
.toggle {
|
||||
--switch--color--background--active: var(--color--primary);
|
||||
}
|
||||
|
||||
.settingsRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -337,6 +339,24 @@ function handlePermissionChange(key: keyof InstanceAiPermissions, value: Instanc
|
|||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.enableSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--xs);
|
||||
}
|
||||
|
||||
.switchRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--spacing--4xs) 0;
|
||||
}
|
||||
|
||||
.switchDescription {
|
||||
font-size: var(--font-size--2xs);
|
||||
color: var(--color--text--tint-1);
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
|
|
|
|||
Loading…
Reference in a new issue