feat(editor): redesign AI Assistant settings

This commit is contained in:
aalises 2026-07-25 16:56:48 +02:00 committed by Albert Alises
parent d3595560b0
commit 8729162f6f
20 changed files with 3111 additions and 860 deletions

View File

@ -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 },

View File

@ -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;

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",

View File

@ -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);
});
});

View File

@ -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>

View File

@ -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>

View File

@ -0,0 +1,48 @@
<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));
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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -0,0 +1,312 @@
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: '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('keeps supported OpenAI organization and custom-header fields editable', async () => {
const credentialsStore = useCredentialsStore();
credentialsStore.setCredentialTypes([OPENAI_TYPE]);
vi.mocked(credentialsStore.getCredentialData).mockResolvedValue({
data: {
apiKey: 'stored-key',
organizationId: 'org-old',
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 } = renderDialog({
props: { kind: 'model', open: true },
});
await findByTestId('param-headerValue');
expect(getByTestId('param-organizationId')).toBeVisible();
expect(getByTestId('param-header')).toBeVisible();
expect(getByTestId('param-headerName')).toBeVisible();
await fireEvent.update(getByTestId('param-organizationId'), 'org-new');
await fireEvent.update(getByTestId('param-headerName'), 'x-new-proxy-key');
await fireEvent.update(getByTestId('param-headerValue'), 'new-value');
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: {
organizationId: 'org-new',
header: true,
headerName: 'x-new-proxy-key',
headerValue: 'new-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());
});
});

View File

@ -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 };
}

View File

@ -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 };
}

View File

@ -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,
};
}

View File

@ -7,6 +7,14 @@ export const INSTANCE_AI_CREDENTIALS_SETTINGS_VIEW = 'InstanceAiCredentialsSetti
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';

View File

@ -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');
}

View File

@ -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,

View File

@ -160,6 +160,16 @@ export const InstanceAiModule: FrontendModuleDescription = {
},
},
},
// Permanent redirect from the removed `/settings/assistant/credentials` page.
{
path: 'assistant/credentials',
redirect: (to) => ({ name: INSTANCE_AI_SETTINGS_VIEW, query: to.query, hash: to.hash }),
meta: {
telemetry: {
pageCategory: 'settings',
},
},
},
],
projectTabs: {
overview: [],

View File

@ -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;
}