mirror of
https://github.com/n8n-io/n8n.git
synced 2026-07-27 11:05:14 +02:00
feat(editor): redesign AI Assistant settings
This commit is contained in:
parent
d3595560b0
commit
8729162f6f
|
|
@ -11,6 +11,19 @@ describe('N8nSettingsPageHeader', () => {
|
|||
expect(title.tagName).toBe('H1');
|
||||
});
|
||||
|
||||
it('renders the titleTrailing slot alongside the title', () => {
|
||||
render(N8nSettingsPageHeader, {
|
||||
props: { title: 'This instance', showDocsLink: false },
|
||||
slots: { titleTrailing: '<span data-test-id="trailing">Preview</span>' },
|
||||
});
|
||||
|
||||
const title = screen.getByText('This instance');
|
||||
const trailing = screen.getByTestId('trailing');
|
||||
expect(title).toBeInTheDocument();
|
||||
expect(trailing).toHaveTextContent('Preview');
|
||||
expect(title.parentElement).toBe(trailing.parentElement);
|
||||
});
|
||||
|
||||
it('renders the title with the requested heading tag', () => {
|
||||
render(N8nSettingsPageHeader, {
|
||||
props: { title: 'This instance', headingTag: 'h2', showDocsLink: false },
|
||||
|
|
|
|||
|
|
@ -55,7 +55,13 @@ if (import.meta.env.DEV) {
|
|||
|
||||
<template>
|
||||
<header :class="$style.header">
|
||||
<N8nHeading :tag="headingTag" :class="$style.title" step="xl" color="text-dark">
|
||||
<div v-if="slots.titleTrailing" :class="$style.titleRow">
|
||||
<N8nHeading :tag="headingTag" :class="$style.title" step="xl" color="text-dark">
|
||||
{{ title }}
|
||||
</N8nHeading>
|
||||
<slot name="titleTrailing" />
|
||||
</div>
|
||||
<N8nHeading v-else :tag="headingTag" :class="$style.title" step="xl" color="text-dark">
|
||||
{{ title }}
|
||||
</N8nHeading>
|
||||
<p v-if="hasDescription || showDocsLink" :class="$style.description">
|
||||
|
|
@ -104,6 +110,12 @@ if (import.meta.env.DEV) {
|
|||
letter-spacing: var(--letter-spacing--tight);
|
||||
}
|
||||
|
||||
.titleRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing--2xs);
|
||||
}
|
||||
|
||||
.description {
|
||||
margin: 0;
|
||||
display: inline;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -34,8 +34,8 @@ const mockFetchSettings = vi.fn();
|
|||
const mockUpdateSettings = vi.fn();
|
||||
const mockFetchPreferences = vi.fn();
|
||||
const mockUpdatePreferences = vi.fn();
|
||||
const mockFetchModelCredentials = vi.fn().mockResolvedValue([]);
|
||||
const mockFetchServiceCredentials = vi.fn().mockResolvedValue([]);
|
||||
const mockFetchInstanceModelCredentials = vi.fn().mockResolvedValue([]);
|
||||
const mockCreateGatewayLink = vi.fn();
|
||||
const mockDisconnectGatewaySession = vi.fn();
|
||||
|
||||
|
|
@ -44,8 +44,8 @@ vi.mock('../instanceAi.settings.api', () => ({
|
|||
updateSettings: (...args: unknown[]) => mockUpdateSettings(...args),
|
||||
fetchPreferences: (...args: unknown[]) => mockFetchPreferences(...args),
|
||||
updatePreferences: (...args: unknown[]) => mockUpdatePreferences(...args),
|
||||
fetchModelCredentials: (...args: unknown[]) => mockFetchModelCredentials(...args),
|
||||
fetchServiceCredentials: (...args: unknown[]) => mockFetchServiceCredentials(...args),
|
||||
fetchInstanceModelCredentials: (...args: unknown[]) => mockFetchInstanceModelCredentials(...args),
|
||||
}));
|
||||
|
||||
const mockGetGatewayStatus = vi.fn();
|
||||
|
|
@ -57,6 +57,7 @@ vi.mock('../instanceAi.api', () => ({
|
|||
|
||||
import { useInstanceAiSettingsStore } from '../instanceAiSettings.store';
|
||||
import { useSettingsStore } from '@/app/stores/settings.store';
|
||||
import { hasPermission } from '@/app/utils/rbac/permissions';
|
||||
|
||||
type InstanceAiModuleSettings = NonNullable<FrontendModuleSettings['instance-ai']>;
|
||||
|
||||
|
|
@ -97,11 +98,34 @@ describe('useInstanceAiSettingsStore', () => {
|
|||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(hasPermission).mockReturnValue(false);
|
||||
setActivePinia(createPinia());
|
||||
store = useInstanceAiSettingsStore();
|
||||
settingsStore = useSettingsStore();
|
||||
});
|
||||
|
||||
describe('permissions', () => {
|
||||
it('checks related admin scopes independently', () => {
|
||||
vi.mocked(hasPermission)
|
||||
.mockReturnValueOnce(true)
|
||||
.mockReturnValueOnce(false)
|
||||
.mockReturnValueOnce(false);
|
||||
|
||||
expect(store.canManage).toBe(true);
|
||||
expect(store.canManageAiUsage).toBe(false);
|
||||
expect(store.canManageInstanceCredentials).toBe(false);
|
||||
expect(hasPermission).toHaveBeenNthCalledWith(1, ['rbac'], {
|
||||
rbac: { scope: 'instanceAi:manage' },
|
||||
});
|
||||
expect(hasPermission).toHaveBeenNthCalledWith(2, ['rbac'], {
|
||||
rbac: { scope: 'aiAssistant:manage' },
|
||||
});
|
||||
expect(hasPermission).toHaveBeenNthCalledWith(3, ['rbac'], {
|
||||
rbac: { scope: 'credential:manageInstance' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('isInstanceAiDisabled', () => {
|
||||
it('returns true when module settings has enabled=false', () => {
|
||||
setModuleSettings(settingsStore, {
|
||||
|
|
@ -256,10 +280,9 @@ describe('useInstanceAiSettingsStore', () => {
|
|||
|
||||
expect(store.isWorkflowBuilderAvailable).toBe(false);
|
||||
expect(store.isSandboxEnabled).toBe(false);
|
||||
expect(store.sandboxUnavailableReason).toBeNull();
|
||||
});
|
||||
|
||||
it('exposes the sandbox unavailable reason from module settings', () => {
|
||||
it('keeps the builder unavailable while the sandbox is enabled', () => {
|
||||
setModuleSettings(settingsStore, {
|
||||
sandboxEnabled: true,
|
||||
workflowBuilderAvailable: false,
|
||||
|
|
@ -268,7 +291,51 @@ describe('useInstanceAiSettingsStore', () => {
|
|||
|
||||
expect(store.isWorkflowBuilderAvailable).toBe(false);
|
||||
expect(store.isSandboxEnabled).toBe(true);
|
||||
expect(store.sandboxUnavailableReason).toBe('N8N_SANDBOX_SERVICE_URL is required.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('settings persistence', () => {
|
||||
const response = {
|
||||
enabled: true,
|
||||
permissions: {},
|
||||
mcpServers: '',
|
||||
mcpAccessEnabled: true,
|
||||
sandboxEnabled: false,
|
||||
sandboxProvider: 'n8n-sandbox',
|
||||
sandboxImage: '',
|
||||
sandboxTimeout: 60,
|
||||
daytonaCredentialId: null,
|
||||
n8nSandboxCredentialId: null,
|
||||
searchCredentialId: null,
|
||||
modelCredentialId: null,
|
||||
modelName: null,
|
||||
modelEnvConfigured: false,
|
||||
sandboxEnvConfigured: false,
|
||||
searchEnvConfigured: false,
|
||||
localGatewayDisabled: false,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
setModuleSettings(settingsStore, { enabled: false });
|
||||
mockUpdateSettings.mockResolvedValue(response);
|
||||
settingsStore.getModuleSettings = vi.fn().mockRejectedValue(new Error('refresh failed'));
|
||||
});
|
||||
|
||||
it('keeps a settings save successful when the module refresh fails', async () => {
|
||||
store.setField('mcpAccessEnabled', true);
|
||||
|
||||
await expect(store.save()).resolves.toBe(true);
|
||||
|
||||
expect(store.settings).toEqual(response);
|
||||
expect(store.draft).toEqual({});
|
||||
expect(settingsStore.moduleSettings['instance-ai']?.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps an enablement save successful when the module refresh fails', async () => {
|
||||
await expect(store.persistEnabled(true)).resolves.toBe(true);
|
||||
|
||||
expect(store.settings).toEqual(response);
|
||||
expect(settingsStore.moduleSettings['instance-ai']?.enabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,39 +0,0 @@
|
|||
<script lang="ts" setup>
|
||||
import { N8nHeading, N8nInput, N8nInputLabel } from '@n8n/design-system';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { useSettingsField } from './useSettingsField';
|
||||
|
||||
const i18n = useI18n();
|
||||
const { store, getString } = useSettingsField();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="$style.section">
|
||||
<N8nHeading tag="h2" size="small">
|
||||
{{ i18n.baseText('instanceAi.settings.section.advanced') }}
|
||||
</N8nHeading>
|
||||
|
||||
<N8nInputLabel
|
||||
:label="i18n.baseText('instanceAi.settings.mcpServers.label')"
|
||||
:bold="false"
|
||||
size="small"
|
||||
>
|
||||
<N8nInput
|
||||
:model-value="getString('mcpServers')"
|
||||
size="small"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
:placeholder="i18n.baseText('instanceAi.settings.mcpServers.placeholder')"
|
||||
@update:model-value="store.setField('mcpServers', $event)"
|
||||
/>
|
||||
</N8nInputLabel>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--xs);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,691 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref, toRaw, watch } from 'vue';
|
||||
import type { ICredentialDataDecryptedObject } from 'n8n-workflow';
|
||||
import {
|
||||
INSTANCE_AI_MODEL_CREDENTIAL_TYPES,
|
||||
INSTANCE_AI_SEARCH_CREDENTIAL_TYPES,
|
||||
type InstanceAiProviderConnection,
|
||||
} from '@n8n/api-types';
|
||||
import {
|
||||
N8nButton,
|
||||
N8nDialog,
|
||||
N8nDialogDescription,
|
||||
N8nDialogFooter,
|
||||
N8nDialogHeader,
|
||||
N8nDialogTitle,
|
||||
N8nInput,
|
||||
N8nInputLabel,
|
||||
N8nOption,
|
||||
N8nSelect,
|
||||
N8nText,
|
||||
} from '@n8n/design-system';
|
||||
import { type BaseTextKey, useI18n } from '@n8n/i18n';
|
||||
import type { IUpdateInformation } from '@/Interface';
|
||||
import Banner from '@/app/components/Banner.vue';
|
||||
import { useLatestFetch } from '@/app/composables/useLatestFetch';
|
||||
import { provideWorkflowDocumentStore } from '@/app/stores/workflowDocument.store';
|
||||
import { useCredentialsStore } from '@/features/credentials/credentials.store';
|
||||
import { SANDBOX_PROVIDER_LABELS, type InstanceAiConnectionKind } from '../../constants';
|
||||
import { useInstanceAiSetupSteps } from '../../composables/useInstanceAiSetupSteps';
|
||||
import { useInstanceCredentialTest } from '../../composables/useInstanceCredentialTest';
|
||||
import { useInstanceAiSettingsStore } from '../../instanceAiSettings.store';
|
||||
import ConnectionFields from './ConnectionFields.vue';
|
||||
|
||||
const DAYTONA_DEFAULT_API_URL = 'https://app.daytona.io/api';
|
||||
const N8N_SANDBOX_HEADER = 'x-api-key';
|
||||
const DEFAULT_SEARCH_TYPE = 'searXngApi';
|
||||
const SANDBOX_CREDENTIAL_TYPES = ['daytonaApi', 'httpHeaderAuth'];
|
||||
const SETUP_STEP: Record<InstanceAiConnectionKind, number> = { model: 1, sandbox: 2, search: 3 };
|
||||
|
||||
const open = defineModel<boolean>('open', { required: true });
|
||||
|
||||
const props = withDefaults(defineProps<{ kind: InstanceAiConnectionKind; setup?: boolean }>(), {
|
||||
setup: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{ saved: []; back: [] }>();
|
||||
|
||||
const i18n = useI18n();
|
||||
const store = useInstanceAiSettingsStore();
|
||||
const credentialsStore = useCredentialsStore();
|
||||
const readOnly = computed(() => !store.canManageInstanceCredentials);
|
||||
const {
|
||||
credentialTestError,
|
||||
isTestingCredential,
|
||||
testCredential,
|
||||
testSavedCredential,
|
||||
restoreStoredError,
|
||||
} = useInstanceCredentialTest();
|
||||
const { stepLabel, isLastStep } = useInstanceAiSetupSteps(SETUP_STEP[props.kind]);
|
||||
|
||||
provideWorkflowDocumentStore();
|
||||
|
||||
/** For 'sandbox' the selection is a provider ('daytona' | 'n8n-sandbox'); otherwise a credential type. */
|
||||
const selection = ref('');
|
||||
const selectedCredentialId = ref('');
|
||||
const selectingExistingCredential = ref(false);
|
||||
const fieldsData = ref<ICredentialDataDecryptedObject>({});
|
||||
/** The one extra input a kind may have: the model name (model) or the API key (n8n sandbox). */
|
||||
const extraValue = ref('');
|
||||
const isLoading = ref(false);
|
||||
const { next: nextHydration } = useLatestFetch();
|
||||
|
||||
let hydratedSelection = '';
|
||||
let hydratedData: ICredentialDataDecryptedObject = {};
|
||||
let hydratedExtra = '';
|
||||
let hydratedSnapshot = '';
|
||||
|
||||
const usingExisting = computed(() => readOnly.value || selectingExistingCredential.value);
|
||||
|
||||
function credentialTypeLabel(type: string) {
|
||||
return credentialsStore.getCredentialTypeByName(type)?.displayName ?? type;
|
||||
}
|
||||
|
||||
interface DialogCopy {
|
||||
idPrefix: string;
|
||||
titleKey: BaseTextKey;
|
||||
setupTitleKey: BaseTextKey;
|
||||
descriptionKey: BaseTextKey;
|
||||
setupDescriptionKey?: BaseTextKey;
|
||||
fieldLabelKey: BaseTextKey;
|
||||
placeholderKey?: BaseTextKey;
|
||||
providerHintKey?: BaseTextKey;
|
||||
footnoteKey?: BaseTextKey;
|
||||
testName: string;
|
||||
}
|
||||
|
||||
const DIALOG_COPY: Record<InstanceAiConnectionKind, DialogCopy> = {
|
||||
model: {
|
||||
idPrefix: 'n8n-agent-model',
|
||||
titleKey: 'settings.n8nAgent.modelDialog.title',
|
||||
setupTitleKey: 'settings.n8nAgent.modelDialog.setupTitle',
|
||||
descriptionKey: 'settings.n8nAgent.modelDialog.description',
|
||||
setupDescriptionKey: 'settings.n8nAgent.modelDialog.setupDescription',
|
||||
fieldLabelKey: 'settings.n8nAgent.modelCredential.field',
|
||||
placeholderKey: 'settings.n8nAgent.modelCredential.placeholder',
|
||||
footnoteKey: 'settings.n8nAgent.modelDialog.footnote',
|
||||
testName: 'AI Assistant model',
|
||||
},
|
||||
sandbox: {
|
||||
idPrefix: 'n8n-agent-sandbox',
|
||||
titleKey: 'settings.n8nAgent.sandboxDialog.title',
|
||||
setupTitleKey: 'settings.n8nAgent.sandboxDialog.setupTitle',
|
||||
descriptionKey: 'settings.n8nAgent.sandboxDialog.description',
|
||||
fieldLabelKey: 'settings.n8nAgent.sandboxDialog.provider',
|
||||
providerHintKey: 'settings.n8nAgent.sandboxDialog.providerHint',
|
||||
testName: 'AI Assistant sandbox',
|
||||
},
|
||||
search: {
|
||||
idPrefix: 'n8n-agent-search',
|
||||
titleKey: 'settings.n8nAgent.searchDialog.title',
|
||||
setupTitleKey: 'settings.n8nAgent.searchDialog.setupTitle',
|
||||
descriptionKey: 'settings.n8nAgent.searchDialog.description',
|
||||
fieldLabelKey: 'settings.n8nAgent.searchCredential.label',
|
||||
placeholderKey: 'settings.n8nAgent.searchCredential.placeholder',
|
||||
testName: 'AI Assistant web search',
|
||||
},
|
||||
};
|
||||
|
||||
const copy = DIALOG_COPY[props.kind];
|
||||
|
||||
function environmentConfigured(): boolean {
|
||||
if (props.kind === 'model') return Boolean(store.settings?.modelEnvConfigured);
|
||||
if (props.kind === 'sandbox') return Boolean(store.settings?.sandboxEnvConfigured);
|
||||
return Boolean(store.settings?.searchEnvConfigured);
|
||||
}
|
||||
|
||||
function getAssignedId(): string | null {
|
||||
if (props.kind === 'model') return store.settings?.modelCredentialId ?? null;
|
||||
if (props.kind === 'sandbox') {
|
||||
return store.settings?.sandboxProvider === 'daytona'
|
||||
? (store.settings?.daytonaCredentialId ?? null)
|
||||
: (store.settings?.n8nSandboxCredentialId ?? null);
|
||||
}
|
||||
return store.settings?.searchCredentialId ?? null;
|
||||
}
|
||||
|
||||
function getAssignedSelection(): string {
|
||||
if (props.kind === 'sandbox') {
|
||||
return getAssignedId() ? (store.settings?.sandboxProvider ?? '') : '';
|
||||
}
|
||||
const credentials =
|
||||
props.kind === 'model' ? store.instanceModelCredentials : store.serviceCredentials;
|
||||
return credentials.find(({ id }) => id === getAssignedId())?.type ?? '';
|
||||
}
|
||||
|
||||
function getDefaultSelection(): string {
|
||||
if (props.kind === 'model') return '';
|
||||
if (props.kind === 'sandbox') {
|
||||
return store.settings?.sandboxEnvConfigured ? '' : (store.settings?.sandboxProvider ?? '');
|
||||
}
|
||||
return store.settings?.searchEnvConfigured ? '' : DEFAULT_SEARCH_TYPE;
|
||||
}
|
||||
|
||||
function getProviderOptions(): Array<{ value: string; label: string }> {
|
||||
if (props.kind === 'sandbox') {
|
||||
return [
|
||||
{ value: 'daytona', label: SANDBOX_PROVIDER_LABELS.daytona },
|
||||
{ value: 'n8n-sandbox', label: SANDBOX_PROVIDER_LABELS['n8n-sandbox'] },
|
||||
];
|
||||
}
|
||||
const credentialTypes =
|
||||
props.kind === 'model'
|
||||
? INSTANCE_AI_MODEL_CREDENTIAL_TYPES
|
||||
: INSTANCE_AI_SEARCH_CREDENTIAL_TYPES;
|
||||
return credentialTypes.map((type) => ({ value: type, label: credentialTypeLabel(type) }));
|
||||
}
|
||||
|
||||
function getExistingCredentials(): InstanceAiProviderConnection[] {
|
||||
if (props.kind === 'model') return store.instanceModelCredentials;
|
||||
const allowedTypes =
|
||||
props.kind === 'sandbox' ? SANDBOX_CREDENTIAL_TYPES : INSTANCE_AI_SEARCH_CREDENTIAL_TYPES;
|
||||
return store.serviceCredentials.filter(({ type }) =>
|
||||
allowedTypes.some((allowed) => allowed === type),
|
||||
);
|
||||
}
|
||||
|
||||
function existingCredentialLabel(credential: InstanceAiProviderConnection): string {
|
||||
const detail =
|
||||
props.kind === 'sandbox'
|
||||
? SANDBOX_PROVIDER_LABELS[credential.type === 'daytonaApi' ? 'daytona' : 'n8n-sandbox']
|
||||
: credentialTypeLabel(credential.type);
|
||||
return `${credential.name} · ${detail}`;
|
||||
}
|
||||
|
||||
function selectionForCredential(credential: InstanceAiProviderConnection): string {
|
||||
if (props.kind !== 'sandbox') return credential.type;
|
||||
return credential.type === 'daytonaApi' ? 'daytona' : 'n8n-sandbox';
|
||||
}
|
||||
|
||||
function credentialTypeFor(selected: string): string {
|
||||
if (props.kind !== 'sandbox') return selected;
|
||||
return selected === 'daytona' ? 'daytonaApi' : 'httpHeaderAuth';
|
||||
}
|
||||
|
||||
function seedData(selected: string): ICredentialDataDecryptedObject {
|
||||
return props.kind === 'sandbox' && selected === 'daytona'
|
||||
? { apiUrl: DAYTONA_DEFAULT_API_URL }
|
||||
: {};
|
||||
}
|
||||
|
||||
function applyLoadedData(data: ICredentialDataDecryptedObject): void {
|
||||
if (props.kind === 'sandbox' && selection.value === 'n8n-sandbox') {
|
||||
extraValue.value = typeof data.value === 'string' ? data.value : '';
|
||||
return;
|
||||
}
|
||||
fieldsData.value = data;
|
||||
}
|
||||
|
||||
function newConnectionIsComplete(selected: string): boolean {
|
||||
if (props.kind === 'model') return true;
|
||||
if (props.kind === 'sandbox') {
|
||||
if (selected === 'n8n-sandbox') return extraValue.value.trim().length > 0;
|
||||
return (
|
||||
typeof fieldsData.value.apiUrl === 'string' &&
|
||||
fieldsData.value.apiUrl.trim().length > 0 &&
|
||||
typeof fieldsData.value.apiKey === 'string' &&
|
||||
fieldsData.value.apiKey.trim().length > 0
|
||||
);
|
||||
}
|
||||
const field = selected === 'braveSearchApi' ? 'apiKey' : 'apiUrl';
|
||||
const value = fieldsData.value[field];
|
||||
return typeof value === 'string' && value.trim().length > 0;
|
||||
}
|
||||
|
||||
function buildConnectionData(): ICredentialDataDecryptedObject {
|
||||
if (props.kind === 'sandbox' && selection.value === 'n8n-sandbox') {
|
||||
return { name: N8N_SANDBOX_HEADER, value: extraValue.value.trim() };
|
||||
}
|
||||
return { ...toRaw(fieldsData.value) };
|
||||
}
|
||||
|
||||
function stageExisting(): void {
|
||||
if (props.kind === 'model') {
|
||||
store.setField('modelCredentialId', selectedCredentialId.value || null);
|
||||
store.setField('modelName', selectedCredentialId.value ? extraValue.value.trim() : undefined);
|
||||
return;
|
||||
}
|
||||
if (props.kind === 'search') {
|
||||
store.setField('searchCredentialId', selectedCredentialId.value || null);
|
||||
return;
|
||||
}
|
||||
store.setField(
|
||||
'daytonaCredentialId',
|
||||
selection.value === 'daytona' ? selectedCredentialId.value : null,
|
||||
);
|
||||
store.setField(
|
||||
'n8nSandboxCredentialId',
|
||||
selection.value === 'n8n-sandbox' ? selectedCredentialId.value : null,
|
||||
);
|
||||
if (selection.value === 'daytona' || selection.value === 'n8n-sandbox') {
|
||||
store.setField('sandboxProvider', selection.value);
|
||||
}
|
||||
}
|
||||
|
||||
function stageNew(connectionData: ICredentialDataDecryptedObject): void {
|
||||
if (props.kind === 'model') {
|
||||
store.setField('modelConnection', { type: selection.value, data: connectionData });
|
||||
store.setField('modelName', extraValue.value.trim());
|
||||
return;
|
||||
}
|
||||
if (props.kind === 'sandbox') {
|
||||
store.setField('sandboxConnection', {
|
||||
type: credentialTypeFor(selection.value),
|
||||
data: connectionData,
|
||||
});
|
||||
return;
|
||||
}
|
||||
store.setField('searchConnection', { type: selection.value, data: connectionData });
|
||||
}
|
||||
|
||||
function stageClear(): void {
|
||||
if (props.kind === 'model') {
|
||||
store.setField('modelConnection', null);
|
||||
store.setField('modelName', undefined);
|
||||
} else if (props.kind === 'sandbox') {
|
||||
store.setField('sandboxConnection', null);
|
||||
} else {
|
||||
store.setField('searchConnection', null);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshCredentials(): Promise<void> {
|
||||
isLoading.value = true;
|
||||
try {
|
||||
if (props.kind === 'model') await store.refreshInstanceModelCredentials();
|
||||
else await store.refreshCredentials();
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const assignedId = computed(getAssignedId);
|
||||
const hasSelection = computed(() =>
|
||||
usingExisting.value ? selectedCredentialId.value : selection.value,
|
||||
);
|
||||
const providerOptions = computed(getProviderOptions);
|
||||
const existingOptions = computed(getExistingCredentials);
|
||||
|
||||
const noneLabel = computed(() =>
|
||||
environmentConfigured()
|
||||
? i18n.baseText('settings.n8nAgent.connection.none')
|
||||
: i18n.baseText('settings.n8nAgent.connection.noneNoEnv'),
|
||||
);
|
||||
|
||||
function snapshot() {
|
||||
return JSON.stringify({
|
||||
c: usingExisting.value ? selectedCredentialId.value : '',
|
||||
e: selectingExistingCredential.value,
|
||||
s: selection.value,
|
||||
d: fieldsData.value,
|
||||
x: extraValue.value,
|
||||
});
|
||||
}
|
||||
|
||||
async function hydrate() {
|
||||
const isCurrent = nextHydration();
|
||||
const credentialId = assignedId.value;
|
||||
extraValue.value = props.kind === 'model' ? (store.settings?.modelName ?? '') : '';
|
||||
selectedCredentialId.value = credentialId ?? '';
|
||||
selectingExistingCredential.value = false;
|
||||
selection.value = readOnly.value
|
||||
? getAssignedSelection()
|
||||
: getAssignedSelection() || getDefaultSelection();
|
||||
fieldsData.value = seedData(selection.value);
|
||||
isLoading.value = false;
|
||||
if (credentialId && !readOnly.value) {
|
||||
isLoading.value = true;
|
||||
try {
|
||||
const credential = await credentialsStore.getCredentialData({ id: credentialId });
|
||||
if (!isCurrent()) return;
|
||||
const data = (
|
||||
credential && 'data' in credential ? (credential.data ?? {}) : {}
|
||||
) as ICredentialDataDecryptedObject;
|
||||
applyLoadedData(data);
|
||||
} catch {
|
||||
if (!isCurrent()) return;
|
||||
fieldsData.value = seedData(selection.value);
|
||||
} finally {
|
||||
if (isCurrent()) isLoading.value = false;
|
||||
}
|
||||
}
|
||||
if (!isCurrent()) return;
|
||||
hydratedSelection = selection.value;
|
||||
hydratedData = { ...fieldsData.value };
|
||||
hydratedExtra = extraValue.value;
|
||||
hydratedSnapshot = snapshot();
|
||||
restoreStoredError(credentialId);
|
||||
}
|
||||
|
||||
watch(
|
||||
open,
|
||||
async (isOpen) => {
|
||||
if (isOpen) await hydrate();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
function selectOption(next: string) {
|
||||
const existingCredential = existingOptions.value.find(({ id }) => id === next);
|
||||
if (existingCredential) {
|
||||
credentialTestError.value = '';
|
||||
selectingExistingCredential.value = true;
|
||||
selectedCredentialId.value = existingCredential.id;
|
||||
selection.value = selectionForCredential(existingCredential);
|
||||
fieldsData.value = {};
|
||||
extraValue.value = existingCredential.id === assignedId.value ? hydratedExtra : '';
|
||||
return;
|
||||
}
|
||||
|
||||
const changedMode = selectingExistingCredential.value;
|
||||
if (next === selection.value && !changedMode) return;
|
||||
credentialTestError.value = '';
|
||||
selectingExistingCredential.value = false;
|
||||
selectedCredentialId.value = '';
|
||||
selection.value = next;
|
||||
// Switching starts from a clean slate; only the hydrated selection keeps its values.
|
||||
fieldsData.value = next === hydratedSelection ? { ...hydratedData } : seedData(next);
|
||||
extraValue.value = next === hydratedSelection ? hydratedExtra : '';
|
||||
}
|
||||
|
||||
function selectCredential(nextCredentialId: string) {
|
||||
if (nextCredentialId === selectedCredentialId.value) return;
|
||||
credentialTestError.value = '';
|
||||
selectedCredentialId.value = nextCredentialId;
|
||||
const credential = existingOptions.value.find(({ id }) => id === nextCredentialId);
|
||||
selection.value = credential ? selectionForCredential(credential) : '';
|
||||
extraValue.value = nextCredentialId === assignedId.value ? hydratedExtra : '';
|
||||
}
|
||||
|
||||
function setFieldValue(name: string, value: IUpdateInformation['value']) {
|
||||
fieldsData.value = { ...fieldsData.value, [name]: value } as ICredentialDataDecryptedObject;
|
||||
}
|
||||
|
||||
const isComplete = computed(() => {
|
||||
if (!hasSelection.value) return true;
|
||||
if (props.kind === 'model' && extraValue.value.trim().length === 0) return false;
|
||||
if (usingExisting.value) return true;
|
||||
return newConnectionIsComplete(selection.value);
|
||||
});
|
||||
const isChanged = computed(() => snapshot() !== hydratedSnapshot);
|
||||
const isBusy = computed(() => store.isSaving || isTestingCredential.value || isLoading.value);
|
||||
const primaryDisabled = computed(() => {
|
||||
if (isBusy.value || !isComplete.value) return true;
|
||||
if (props.setup) return !isChanged.value && !hasSelection.value;
|
||||
return !isChanged.value && !credentialTestError.value;
|
||||
});
|
||||
|
||||
async function handlePrimary() {
|
||||
const connectionData = buildConnectionData();
|
||||
if (
|
||||
!usingExisting.value &&
|
||||
selection.value &&
|
||||
!(await testCredential({
|
||||
id: selection.value === getAssignedSelection() ? (assignedId.value ?? '') : '',
|
||||
name: copy.testName,
|
||||
type: credentialTypeFor(selection.value),
|
||||
data: connectionData,
|
||||
}))
|
||||
)
|
||||
return;
|
||||
if (!open.value) return;
|
||||
|
||||
// Pre-test a selected existing connection instead of relying on backend validation alone.
|
||||
// Read-only callers cannot fetch credential data, so the backend stays their safety net.
|
||||
if (selectingExistingCredential.value && !readOnly.value && selectedCredentialId.value) {
|
||||
const credential = existingOptions.value.find(({ id }) => id === selectedCredentialId.value);
|
||||
if (credential && !(await testSavedCredential(credential.id, credential.name, credential.type)))
|
||||
return;
|
||||
if (!open.value) return;
|
||||
}
|
||||
|
||||
if (isChanged.value) {
|
||||
if (usingExisting.value) stageExisting();
|
||||
else if (!selection.value) stageClear();
|
||||
else stageNew(connectionData);
|
||||
if (!(await store.save())) return;
|
||||
}
|
||||
await refreshCredentials();
|
||||
if (!open.value) return;
|
||||
// Emit before closing so the host can transition to the next dialog without an all-closed gap.
|
||||
emit('saved');
|
||||
open.value = false;
|
||||
}
|
||||
|
||||
function handleBack() {
|
||||
if (isBusy.value) return;
|
||||
emit('back');
|
||||
open.value = false;
|
||||
}
|
||||
|
||||
function handleOpenChange(value: boolean) {
|
||||
if (!value && isBusy.value) return;
|
||||
open.value = value;
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
handleOpenChange(false);
|
||||
}
|
||||
|
||||
const title = computed(() => i18n.baseText(props.setup ? copy.setupTitleKey : copy.titleKey));
|
||||
const description = computed(() =>
|
||||
i18n.baseText(
|
||||
props.setup && copy.setupDescriptionKey ? copy.setupDescriptionKey : copy.descriptionKey,
|
||||
),
|
||||
);
|
||||
const showCancel = computed(() => !props.setup || props.kind === 'model');
|
||||
const primaryLabel = computed(() => {
|
||||
if (credentialTestError.value) return i18n.baseText('credentialEdit.credentialConfig.retry');
|
||||
if (props.setup && (props.kind === 'model' || (props.kind === 'sandbox' && !isLastStep.value)))
|
||||
return i18n.baseText('settings.n8nAgent.setup.continue');
|
||||
return i18n.baseText('generic.save');
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<N8nDialog
|
||||
:open="open"
|
||||
size="medium"
|
||||
:show-close-button="!isBusy"
|
||||
:data-test-id="`${copy.idPrefix}-dialog`"
|
||||
@update:open="handleOpenChange"
|
||||
>
|
||||
<N8nDialogHeader>
|
||||
<N8nText
|
||||
v-if="setup"
|
||||
:class="$style.step"
|
||||
size="xsmall"
|
||||
color="text-light"
|
||||
bold
|
||||
tag="p"
|
||||
:data-test-id="`${copy.idPrefix}-dialog-step`"
|
||||
>
|
||||
{{ stepLabel }}
|
||||
</N8nText>
|
||||
<N8nDialogTitle>{{ title }}</N8nDialogTitle>
|
||||
<N8nDialogDescription>{{ description }}</N8nDialogDescription>
|
||||
</N8nDialogHeader>
|
||||
|
||||
<div :class="$style.fields">
|
||||
<N8nInputLabel :label="i18n.baseText(copy.fieldLabelKey)">
|
||||
<N8nSelect
|
||||
v-if="readOnly"
|
||||
:model-value="selectedCredentialId"
|
||||
size="medium"
|
||||
:disabled="isBusy"
|
||||
:placeholder="copy.placeholderKey ? i18n.baseText(copy.placeholderKey) : undefined"
|
||||
:data-test-id="`${copy.idPrefix}-provider-select`"
|
||||
@update:model-value="selectCredential(String($event ?? ''))"
|
||||
>
|
||||
<N8nOption v-if="!setup" value="" :label="noneLabel" />
|
||||
<N8nOption
|
||||
v-for="credential in existingOptions"
|
||||
:key="credential.id"
|
||||
:value="credential.id"
|
||||
:label="existingCredentialLabel(credential)"
|
||||
/>
|
||||
</N8nSelect>
|
||||
<N8nSelect
|
||||
v-else
|
||||
:model-value="selectingExistingCredential ? selectedCredentialId : selection"
|
||||
size="medium"
|
||||
:disabled="isBusy"
|
||||
:placeholder="copy.placeholderKey ? i18n.baseText(copy.placeholderKey) : undefined"
|
||||
:data-test-id="`${copy.idPrefix}-provider-select`"
|
||||
@update:model-value="selectOption(String($event ?? ''))"
|
||||
>
|
||||
<N8nOption v-if="!setup" value="" :label="noneLabel" />
|
||||
<N8nOption
|
||||
v-for="option in providerOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
:label="option.label"
|
||||
/>
|
||||
<N8nOption
|
||||
v-for="credential in existingOptions"
|
||||
:key="credential.id"
|
||||
:value="credential.id"
|
||||
:label="existingCredentialLabel(credential)"
|
||||
/>
|
||||
</N8nSelect>
|
||||
<N8nText
|
||||
v-if="copy.providerHintKey"
|
||||
tag="p"
|
||||
:class="$style.providerHint"
|
||||
size="small"
|
||||
color="text-light"
|
||||
>
|
||||
{{ i18n.baseText(copy.providerHintKey) }}
|
||||
</N8nText>
|
||||
</N8nInputLabel>
|
||||
|
||||
<ConnectionFields
|
||||
v-if="
|
||||
!usingExisting &&
|
||||
selection &&
|
||||
(kind !== 'sandbox' || selection === 'daytona') &&
|
||||
!isLoading
|
||||
"
|
||||
:credential-type="credentialTypeFor(selection)"
|
||||
:data="fieldsData"
|
||||
:disabled="isBusy"
|
||||
:data-test-id="`${copy.idPrefix}-connection-fields`"
|
||||
@update="setFieldValue"
|
||||
/>
|
||||
|
||||
<N8nInputLabel
|
||||
v-if="kind === 'model' && hasSelection"
|
||||
:label="i18n.baseText('settings.n8nAgent.modelName.label')"
|
||||
>
|
||||
<N8nInput
|
||||
:model-value="extraValue"
|
||||
type="text"
|
||||
size="medium"
|
||||
:disabled="isBusy"
|
||||
autocomplete="off"
|
||||
:spellcheck="false"
|
||||
:placeholder="i18n.baseText('settings.n8nAgent.modelName.placeholder')"
|
||||
data-test-id="n8n-agent-model-name-input"
|
||||
@update:model-value="extraValue = String($event)"
|
||||
/>
|
||||
</N8nInputLabel>
|
||||
<N8nInputLabel
|
||||
v-else-if="kind === 'sandbox' && !usingExisting && selection === 'n8n-sandbox'"
|
||||
:label="i18n.baseText('settings.n8nAgent.sandboxCredential.apiKey')"
|
||||
>
|
||||
<N8nInput
|
||||
:model-value="extraValue"
|
||||
type="password"
|
||||
size="medium"
|
||||
:disabled="isBusy"
|
||||
autocomplete="off"
|
||||
:spellcheck="false"
|
||||
data-test-id="n8n-agent-sandbox-api-key-input"
|
||||
@update:model-value="extraValue = String($event)"
|
||||
/>
|
||||
</N8nInputLabel>
|
||||
</div>
|
||||
|
||||
<N8nText
|
||||
v-if="copy.footnoteKey"
|
||||
:class="$style.footnote"
|
||||
size="small"
|
||||
color="text-light"
|
||||
tag="p"
|
||||
>
|
||||
{{ i18n.baseText(copy.footnoteKey) }}
|
||||
</N8nText>
|
||||
<Banner
|
||||
v-if="credentialTestError"
|
||||
theme="danger"
|
||||
:message="i18n.baseText('credentialEdit.credentialConfig.couldntConnectWithTheseSettings')"
|
||||
:details="credentialTestError"
|
||||
:data-test-id="`${copy.idPrefix}-credential-test-error`"
|
||||
/>
|
||||
|
||||
<N8nDialogFooter>
|
||||
<N8nButton
|
||||
v-if="setup && kind !== 'model'"
|
||||
variant="outline"
|
||||
size="medium"
|
||||
:label="i18n.baseText('generic.back')"
|
||||
:disabled="isBusy"
|
||||
:data-test-id="`${copy.idPrefix}-dialog-back`"
|
||||
@click="handleBack"
|
||||
/>
|
||||
<N8nButton
|
||||
v-if="setup && kind === 'search'"
|
||||
variant="outline"
|
||||
size="medium"
|
||||
:label="i18n.baseText('settings.n8nAgent.setup.skip')"
|
||||
:disabled="isBusy"
|
||||
:data-test-id="`${copy.idPrefix}-dialog-skip`"
|
||||
@click="handleClose"
|
||||
/>
|
||||
<N8nButton
|
||||
v-if="showCancel"
|
||||
variant="outline"
|
||||
size="medium"
|
||||
:label="i18n.baseText('generic.cancel')"
|
||||
:disabled="isBusy"
|
||||
:data-test-id="`${copy.idPrefix}-dialog-cancel`"
|
||||
@click="handleClose"
|
||||
/>
|
||||
<N8nButton
|
||||
variant="solid"
|
||||
size="medium"
|
||||
:label="primaryLabel"
|
||||
:loading="isTestingCredential"
|
||||
:disabled="primaryDisabled"
|
||||
:data-test-id="`${copy.idPrefix}-dialog-save`"
|
||||
@click="handlePrimary"
|
||||
/>
|
||||
</N8nDialogFooter>
|
||||
</N8nDialog>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--sm);
|
||||
margin: var(--spacing--sm) 0;
|
||||
// Long credential forms scroll inside the dialog instead of growing past the viewport.
|
||||
max-height: calc(100dvh - 20rem);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.providerHint {
|
||||
margin: var(--spacing--4xs) 0 0;
|
||||
}
|
||||
|
||||
.footnote {
|
||||
margin: 0 0 var(--spacing--sm);
|
||||
}
|
||||
|
||||
.step {
|
||||
margin: 0;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: var(--letter-spacing--wide);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,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>
|
||||
|
|
@ -1,69 +0,0 @@
|
|||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import { N8nHeading, N8nInput, N8nSelect, N8nOption, N8nInputLabel } from '@n8n/design-system';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { useSettingsField } from './useSettingsField';
|
||||
|
||||
const i18n = useI18n();
|
||||
const { store, getPreferenceString } = useSettingsField();
|
||||
|
||||
const selectedCredentialId = computed(() => {
|
||||
if (store.preferencesDraft.credentialId !== undefined)
|
||||
return store.preferencesDraft.credentialId ?? '';
|
||||
return store.preferences?.credentialId ?? '';
|
||||
});
|
||||
|
||||
function handleCredentialChange(value: string | number | boolean | null) {
|
||||
store.setPreferenceField('credentialId', value ? String(value) : null);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="$style.section">
|
||||
<N8nHeading tag="h2" size="small">
|
||||
{{ i18n.baseText('instanceAi.settings.section.model') }}
|
||||
</N8nHeading>
|
||||
|
||||
<N8nInputLabel
|
||||
:label="i18n.baseText('instanceAi.settings.credential.label')"
|
||||
:bold="false"
|
||||
size="small"
|
||||
>
|
||||
<N8nSelect
|
||||
:model-value="selectedCredentialId"
|
||||
size="small"
|
||||
:placeholder="i18n.baseText('instanceAi.settings.credential.placeholder')"
|
||||
@update:model-value="handleCredentialChange"
|
||||
>
|
||||
<N8nOption value="" :label="i18n.baseText('instanceAi.settings.credential.none')" />
|
||||
<N8nOption
|
||||
v-for="cred in store.credentials"
|
||||
:key="cred.id"
|
||||
:value="cred.id"
|
||||
:label="`${cred.name} (${cred.provider})`"
|
||||
/>
|
||||
</N8nSelect>
|
||||
</N8nInputLabel>
|
||||
|
||||
<N8nInputLabel
|
||||
:label="i18n.baseText('instanceAi.settings.modelName.label')"
|
||||
:bold="false"
|
||||
size="small"
|
||||
>
|
||||
<N8nInput
|
||||
:model-value="getPreferenceString('modelName')"
|
||||
size="small"
|
||||
:placeholder="i18n.baseText('instanceAi.settings.modelName.placeholder')"
|
||||
@update:model-value="store.setPreferenceField('modelName', $event)"
|
||||
/>
|
||||
</N8nInputLabel>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--xs);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,259 +0,0 @@
|
|||
<script lang="ts" setup>
|
||||
import { computed, watch } from 'vue';
|
||||
import {
|
||||
N8nHeading,
|
||||
N8nInput,
|
||||
N8nInputNumber,
|
||||
N8nSelect,
|
||||
N8nOption,
|
||||
N8nInputLabel,
|
||||
} from '@n8n/design-system';
|
||||
import { ElSwitch } from 'element-plus';
|
||||
import { type BaseTextKey, useI18n } from '@n8n/i18n';
|
||||
import { useUIStore } from '@/app/stores/ui.store';
|
||||
import { CREDENTIAL_EDIT_MODAL_KEY } from '@/features/credentials/credentials.constants';
|
||||
import { useSettingsField } from './useSettingsField';
|
||||
|
||||
const CREATE_NEW = '__create__';
|
||||
|
||||
const i18n = useI18n();
|
||||
const uiStore = useUIStore();
|
||||
const { store, getString, getNumber, getBool } = useSettingsField();
|
||||
|
||||
const provider = computed(() => {
|
||||
return store.draft.sandboxProvider ?? store.settings?.sandboxProvider ?? 'n8n-sandbox';
|
||||
});
|
||||
|
||||
const showDaytonaFields = computed(() => {
|
||||
return provider.value === 'daytona';
|
||||
});
|
||||
|
||||
const showN8nSandboxFields = computed(() => {
|
||||
return provider.value === 'n8n-sandbox';
|
||||
});
|
||||
|
||||
const showImageField = computed(() => {
|
||||
return provider.value === 'daytona';
|
||||
});
|
||||
|
||||
function handleProviderChange(value: string | number | boolean | null) {
|
||||
if (value === 'n8n-sandbox' || value === 'daytona') {
|
||||
store.setField('sandboxProvider', value);
|
||||
}
|
||||
}
|
||||
|
||||
const daytonaCredentials = computed(() =>
|
||||
store.serviceCredentials.filter((c) => c.type === 'daytonaApi'),
|
||||
);
|
||||
|
||||
const n8nSandboxCredentials = computed(() =>
|
||||
store.serviceCredentials.filter((c) => c.type === 'httpHeaderAuth'),
|
||||
);
|
||||
|
||||
const selectedDaytonaCredentialId = computed(() => {
|
||||
if (store.draft.daytonaCredentialId !== undefined) return store.draft.daytonaCredentialId ?? '';
|
||||
return store.settings?.daytonaCredentialId ?? '';
|
||||
});
|
||||
|
||||
const selectedN8nSandboxCredentialId = computed(() => {
|
||||
if (store.draft.n8nSandboxCredentialId !== undefined) {
|
||||
return store.draft.n8nSandboxCredentialId ?? '';
|
||||
}
|
||||
return store.settings?.n8nSandboxCredentialId ?? '';
|
||||
});
|
||||
|
||||
let creatingCredential = false;
|
||||
let creatingCredentialType: 'daytonaApi' | 'httpHeaderAuth' | null = null;
|
||||
|
||||
function handleDaytonaCredentialChange(value: string | number | boolean | null) {
|
||||
if (value === CREATE_NEW) {
|
||||
creatingCredential = true;
|
||||
creatingCredentialType = 'daytonaApi';
|
||||
uiStore.openNewCredential('daytonaApi');
|
||||
return;
|
||||
}
|
||||
store.setField('daytonaCredentialId', value ? String(value) : null);
|
||||
}
|
||||
|
||||
function handleN8nSandboxCredentialChange(value: string | number | boolean | null) {
|
||||
if (value === CREATE_NEW) {
|
||||
creatingCredential = true;
|
||||
creatingCredentialType = 'httpHeaderAuth';
|
||||
uiStore.openNewCredential('httpHeaderAuth');
|
||||
return;
|
||||
}
|
||||
store.setField('n8nSandboxCredentialId', value ? String(value) : null);
|
||||
}
|
||||
|
||||
// Re-fetch credentials when the credential edit modal closes, auto-select if newly created
|
||||
watch(
|
||||
() => uiStore.isModalActiveById[CREDENTIAL_EDIT_MODAL_KEY],
|
||||
async (isOpen, wasOpen) => {
|
||||
if (!wasOpen || isOpen) return;
|
||||
const previousIds = new Set(daytonaCredentials.value.map((c) => c.id));
|
||||
const previousSandboxIds = new Set(n8nSandboxCredentials.value.map((c) => c.id));
|
||||
await store.refreshCredentials();
|
||||
if (creatingCredential) {
|
||||
creatingCredential = false;
|
||||
if (creatingCredentialType === 'daytonaApi') {
|
||||
const newCred = daytonaCredentials.value.find((c) => !previousIds.has(c.id));
|
||||
if (newCred) {
|
||||
store.setField('daytonaCredentialId', newCred.id);
|
||||
}
|
||||
}
|
||||
if (creatingCredentialType === 'httpHeaderAuth') {
|
||||
const newCred = n8nSandboxCredentials.value.find((c) => !previousSandboxIds.has(c.id));
|
||||
if (newCred) {
|
||||
store.setField('n8nSandboxCredentialId', newCred.id);
|
||||
}
|
||||
}
|
||||
creatingCredentialType = null;
|
||||
}
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="$style.section">
|
||||
<N8nHeading tag="h2" size="small">
|
||||
{{ i18n.baseText('instanceAi.settings.section.sandbox') }}
|
||||
</N8nHeading>
|
||||
|
||||
<div :class="$style.switchRow">
|
||||
<span :class="$style.switchLabel">{{
|
||||
i18n.baseText('instanceAi.settings.sandboxEnabled.label')
|
||||
}}</span>
|
||||
<ElSwitch
|
||||
:model-value="getBool('sandboxEnabled')"
|
||||
@update:model-value="store.setField('sandboxEnabled', Boolean($event))"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<N8nInputLabel
|
||||
:label="i18n.baseText('instanceAi.settings.sandboxProvider.label')"
|
||||
:bold="false"
|
||||
size="small"
|
||||
>
|
||||
<N8nSelect :model-value="provider" size="small" @update:model-value="handleProviderChange">
|
||||
<N8nOption value="n8n-sandbox" label="n8n Sandbox Service" />
|
||||
<N8nOption value="daytona" label="Daytona" />
|
||||
</N8nSelect>
|
||||
</N8nInputLabel>
|
||||
|
||||
<template v-if="showDaytonaFields">
|
||||
<N8nInputLabel
|
||||
:label="i18n.baseText('instanceAi.settings.daytonaCredential.label')"
|
||||
:bold="false"
|
||||
size="small"
|
||||
>
|
||||
<N8nSelect
|
||||
:model-value="selectedDaytonaCredentialId"
|
||||
size="small"
|
||||
:placeholder="i18n.baseText('instanceAi.settings.credential.placeholder')"
|
||||
@update:model-value="handleDaytonaCredentialChange"
|
||||
>
|
||||
<N8nOption value="" :label="i18n.baseText('instanceAi.settings.credential.none')" />
|
||||
<N8nOption
|
||||
v-for="cred in daytonaCredentials"
|
||||
:key="cred.id"
|
||||
:value="cred.id"
|
||||
:label="cred.name"
|
||||
/>
|
||||
<N8nOption
|
||||
:value="CREATE_NEW"
|
||||
:label="i18n.baseText('instanceAi.settings.credential.createNew')"
|
||||
/>
|
||||
</N8nSelect>
|
||||
</N8nInputLabel>
|
||||
</template>
|
||||
|
||||
<template v-if="showN8nSandboxFields">
|
||||
<N8nInputLabel
|
||||
:label="i18n.baseText('instanceAi.settings.n8nSandboxCredential.label' as BaseTextKey)"
|
||||
:bold="false"
|
||||
size="small"
|
||||
>
|
||||
<N8nSelect
|
||||
:model-value="selectedN8nSandboxCredentialId"
|
||||
size="small"
|
||||
:placeholder="i18n.baseText('instanceAi.settings.credential.placeholder')"
|
||||
@update:model-value="handleN8nSandboxCredentialChange"
|
||||
>
|
||||
<N8nOption value="" :label="i18n.baseText('instanceAi.settings.credential.none')" />
|
||||
<N8nOption
|
||||
v-for="cred in n8nSandboxCredentials"
|
||||
:key="cred.id"
|
||||
:value="cred.id"
|
||||
:label="cred.name"
|
||||
/>
|
||||
<N8nOption
|
||||
:value="CREATE_NEW"
|
||||
:label="i18n.baseText('instanceAi.settings.credential.createNew')"
|
||||
/>
|
||||
</N8nSelect>
|
||||
<p :class="$style.hint">
|
||||
{{ i18n.baseText('instanceAi.settings.n8nSandbox.serviceUrlHint' as BaseTextKey) }}
|
||||
</p>
|
||||
</N8nInputLabel>
|
||||
</template>
|
||||
|
||||
<N8nInputLabel
|
||||
v-if="showImageField"
|
||||
:label="i18n.baseText('instanceAi.settings.sandboxImage.label')"
|
||||
:bold="false"
|
||||
size="small"
|
||||
>
|
||||
<N8nInput
|
||||
:model-value="getString('sandboxImage')"
|
||||
size="small"
|
||||
@update:model-value="store.setField('sandboxImage', $event)"
|
||||
/>
|
||||
</N8nInputLabel>
|
||||
|
||||
<N8nInputLabel
|
||||
:label="i18n.baseText('instanceAi.settings.sandboxTimeout.label')"
|
||||
:bold="false"
|
||||
size="small"
|
||||
>
|
||||
<N8nInputNumber
|
||||
:class="$style.numberInput"
|
||||
:model-value="getNumber('sandboxTimeout') ?? 300000"
|
||||
size="small"
|
||||
:min="0"
|
||||
:step="10000"
|
||||
@update:model-value="store.setField('sandboxTimeout', $event ?? 300000)"
|
||||
/>
|
||||
</N8nInputLabel>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--xs);
|
||||
}
|
||||
|
||||
.numberInput {
|
||||
max-width: 140px;
|
||||
}
|
||||
|
||||
.switchRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--spacing--4xs) 0;
|
||||
}
|
||||
|
||||
.switchLabel {
|
||||
font-size: var(--font-size--2xs);
|
||||
color: var(--color--text--tint-1);
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: var(--spacing--4xs) 0 0;
|
||||
font-size: var(--font-size--2xs);
|
||||
line-height: var(--line-height--sm);
|
||||
color: var(--color--text--tint-1);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,101 +0,0 @@
|
|||
<script lang="ts" setup>
|
||||
import { computed, watch } from 'vue';
|
||||
import { N8nHeading, N8nSelect, N8nOption, N8nInputLabel } from '@n8n/design-system';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { useUIStore } from '@/app/stores/ui.store';
|
||||
import { CREDENTIAL_EDIT_MODAL_KEY } from '@/features/credentials/credentials.constants';
|
||||
import { useSettingsField } from './useSettingsField';
|
||||
|
||||
const CREATE_BRAVE = '__create_brave__';
|
||||
const CREATE_SEARXNG = '__create_searxng__';
|
||||
|
||||
const i18n = useI18n();
|
||||
const uiStore = useUIStore();
|
||||
const { store } = useSettingsField();
|
||||
|
||||
const searchCredentials = computed(() =>
|
||||
store.serviceCredentials.filter((c) => c.type === 'braveSearchApi' || c.type === 'searXngApi'),
|
||||
);
|
||||
|
||||
const selectedSearchCredentialId = computed(() => {
|
||||
if (store.draft.searchCredentialId !== undefined) return store.draft.searchCredentialId ?? '';
|
||||
return store.settings?.searchCredentialId ?? '';
|
||||
});
|
||||
|
||||
let creatingCredential = false;
|
||||
|
||||
function handleSearchCredentialChange(value: string | number | boolean | null) {
|
||||
if (value === CREATE_BRAVE) {
|
||||
creatingCredential = true;
|
||||
uiStore.openNewCredential('braveSearchApi');
|
||||
return;
|
||||
}
|
||||
if (value === CREATE_SEARXNG) {
|
||||
creatingCredential = true;
|
||||
uiStore.openNewCredential('searXngApi');
|
||||
return;
|
||||
}
|
||||
store.setField('searchCredentialId', value ? String(value) : null);
|
||||
}
|
||||
|
||||
watch(
|
||||
() => uiStore.isModalActiveById[CREDENTIAL_EDIT_MODAL_KEY],
|
||||
async (isOpen, wasOpen) => {
|
||||
if (!wasOpen || isOpen) return;
|
||||
const previousIds = new Set(searchCredentials.value.map((c) => c.id));
|
||||
await store.refreshCredentials();
|
||||
if (creatingCredential) {
|
||||
creatingCredential = false;
|
||||
const newCred = searchCredentials.value.find((c) => !previousIds.has(c.id));
|
||||
if (newCred) {
|
||||
store.setField('searchCredentialId', newCred.id);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="$style.section">
|
||||
<N8nHeading tag="h2" size="small">
|
||||
{{ i18n.baseText('instanceAi.settings.section.search') }}
|
||||
</N8nHeading>
|
||||
|
||||
<N8nInputLabel
|
||||
:label="i18n.baseText('instanceAi.settings.searchCredential.label')"
|
||||
:bold="false"
|
||||
size="small"
|
||||
>
|
||||
<N8nSelect
|
||||
:model-value="selectedSearchCredentialId"
|
||||
size="small"
|
||||
:placeholder="i18n.baseText('instanceAi.settings.credential.placeholder')"
|
||||
@update:model-value="handleSearchCredentialChange"
|
||||
>
|
||||
<N8nOption value="" :label="i18n.baseText('instanceAi.settings.credential.none')" />
|
||||
<N8nOption
|
||||
v-for="cred in searchCredentials"
|
||||
:key="cred.id"
|
||||
:value="cred.id"
|
||||
:label="`${cred.name} (${cred.type === 'braveSearchApi' ? 'Brave' : 'SearXNG'})`"
|
||||
/>
|
||||
<N8nOption
|
||||
:value="CREATE_BRAVE"
|
||||
:label="i18n.baseText('instanceAi.settings.credential.createBrave')"
|
||||
/>
|
||||
<N8nOption
|
||||
:value="CREATE_SEARXNG"
|
||||
:label="i18n.baseText('instanceAi.settings.credential.createSearxng')"
|
||||
/>
|
||||
</N8nSelect>
|
||||
</N8nInputLabel>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--xs);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,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());
|
||||
});
|
||||
});
|
||||
|
|
@ -1,64 +0,0 @@
|
|||
import { useInstanceAiSettingsStore } from '../../instanceAiSettings.store';
|
||||
import type {
|
||||
InstanceAiAdminSettingsResponse,
|
||||
InstanceAiAdminSettingsUpdateRequest,
|
||||
InstanceAiUserPreferencesResponse,
|
||||
InstanceAiUserPreferencesUpdateRequest,
|
||||
} from '@n8n/api-types';
|
||||
|
||||
type StringField = keyof {
|
||||
[K in keyof InstanceAiAdminSettingsResponse as InstanceAiAdminSettingsResponse[K] extends string
|
||||
? K
|
||||
: never]: true;
|
||||
};
|
||||
type NumberField = keyof {
|
||||
[K in keyof InstanceAiAdminSettingsResponse as InstanceAiAdminSettingsResponse[K] extends number
|
||||
? K
|
||||
: never]: true;
|
||||
};
|
||||
type BoolField = keyof {
|
||||
[K in keyof InstanceAiAdminSettingsResponse as InstanceAiAdminSettingsResponse[K] extends boolean
|
||||
? K
|
||||
: never]: true;
|
||||
};
|
||||
|
||||
type PreferenceStringField = keyof {
|
||||
[K in keyof InstanceAiUserPreferencesResponse as InstanceAiUserPreferencesResponse[K] extends string
|
||||
? K
|
||||
: never]: true;
|
||||
};
|
||||
|
||||
export function useSettingsField() {
|
||||
const store = useInstanceAiSettingsStore();
|
||||
|
||||
function getString(key: StringField & keyof InstanceAiAdminSettingsUpdateRequest): string {
|
||||
const draftVal = store.draft[key];
|
||||
if (draftVal !== undefined) return String(draftVal);
|
||||
return store.settings?.[key] ?? '';
|
||||
}
|
||||
|
||||
function getNumber(key: NumberField & keyof InstanceAiAdminSettingsUpdateRequest): number {
|
||||
const draftVal = store.draft[key];
|
||||
if (draftVal !== undefined) return Number(draftVal);
|
||||
return store.settings?.[key] ?? 0;
|
||||
}
|
||||
|
||||
function getBool(key: BoolField & keyof InstanceAiAdminSettingsUpdateRequest): boolean {
|
||||
const draftVal = store.draft[key];
|
||||
if (draftVal !== undefined) return Boolean(draftVal);
|
||||
const fromSettings = store.settings?.[key];
|
||||
if (fromSettings !== undefined) return Boolean(fromSettings);
|
||||
if (key === 'enabled') return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function getPreferenceString(
|
||||
key: PreferenceStringField & keyof InstanceAiUserPreferencesUpdateRequest,
|
||||
): string {
|
||||
const draftVal = store.preferencesDraft[key];
|
||||
if (draftVal !== undefined) return String(draftVal);
|
||||
return store.preferences?.[key] ?? '';
|
||||
}
|
||||
|
||||
return { store, getString, getNumber, getBool, getPreferenceString };
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
import { computed } from 'vue';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { useInstanceAiSettingsStore } from '../instanceAiSettings.store';
|
||||
|
||||
/** Step labels for the setup wizard; the optional search step drops off when already configured. */
|
||||
export function useInstanceAiSetupSteps(step: number) {
|
||||
const i18n = useI18n();
|
||||
const store = useInstanceAiSettingsStore();
|
||||
|
||||
const totalSteps = computed(() =>
|
||||
(store.settings?.searchCredentialId ?? store.settings?.searchEnvConfigured) ? 2 : 3,
|
||||
);
|
||||
const stepLabel = computed(() =>
|
||||
i18n.baseText('settings.n8nAgent.setup.step', {
|
||||
interpolate: { step, total: totalSteps.value },
|
||||
}),
|
||||
);
|
||||
const isLastStep = computed(() => step >= totalSteps.value);
|
||||
|
||||
return { stepLabel, isLastStep };
|
||||
}
|
||||
|
|
@ -0,0 +1,112 @@
|
|||
import { ref } from 'vue';
|
||||
import { i18n } from '@n8n/i18n';
|
||||
import {
|
||||
displayParameter,
|
||||
type ICredentialDataDecryptedObject,
|
||||
type ICredentialsDecrypted,
|
||||
} from 'n8n-workflow';
|
||||
import { useCredentialsStore } from '@/features/credentials/credentials.store';
|
||||
import { useCredentialTestInBackground } from '@/features/credentials/composables/useCredentialTestInBackground';
|
||||
|
||||
const genericError = () => i18n.baseText('instanceAi.workflowSetup.credentialTestFailedTooltip');
|
||||
|
||||
export function useInstanceCredentialTest() {
|
||||
const credentialsStore = useCredentialsStore();
|
||||
const { isCredentialTypeTestable } = useCredentialTestInBackground();
|
||||
const credentialTestError = ref('');
|
||||
const isTestingCredential = ref(false);
|
||||
|
||||
function hasRequiredData(credentials: ICredentialsDecrypted): boolean {
|
||||
const data = credentials.data ?? {};
|
||||
return (credentialsStore.getCredentialTypeByName(credentials.type)?.properties ?? []).every(
|
||||
(property) => {
|
||||
if (!property.required || !displayParameter(data, property, null, null)) return true;
|
||||
const value = data[property.name];
|
||||
const hasValue =
|
||||
typeof value === 'string'
|
||||
? value.trim().length > 0
|
||||
: value !== null && value !== undefined;
|
||||
return hasValue || (property.default !== undefined && property.default !== '');
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function runCredentialTest(credentials: ICredentialsDecrypted): Promise<boolean> {
|
||||
const result = await credentialsStore.testCredential(credentials);
|
||||
if (result.status === 'OK') return true;
|
||||
|
||||
credentialTestError.value = result.message || genericError();
|
||||
return false;
|
||||
}
|
||||
|
||||
async function testCredential(credentials: ICredentialsDecrypted): Promise<boolean> {
|
||||
credentialTestError.value = '';
|
||||
if (!isCredentialTypeTestable(credentials.type)) {
|
||||
const isValid = hasRequiredData(credentials);
|
||||
if (!isValid) credentialTestError.value = genericError();
|
||||
return isValid;
|
||||
}
|
||||
|
||||
isTestingCredential.value = true;
|
||||
try {
|
||||
return await runCredentialTest(credentials);
|
||||
} catch {
|
||||
credentialTestError.value = genericError();
|
||||
return false;
|
||||
} finally {
|
||||
isTestingCredential.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Results land in credentialsStore.credentialTestResults (keyed by credential id) so any
|
||||
// consumer — view pre-flight or a dialog — can resurrect the failure via restoreStoredError.
|
||||
async function testSavedCredential(id: string, name: string, type: string): Promise<boolean> {
|
||||
if (!isCredentialTypeTestable(type)) return true;
|
||||
|
||||
credentialTestError.value = '';
|
||||
isTestingCredential.value = true;
|
||||
try {
|
||||
const credential = await credentialsStore.getCredentialData({ id });
|
||||
const credentialData = credential && 'data' in credential ? credential.data : undefined;
|
||||
if (!credentialData || typeof credentialData === 'string') throw new Error();
|
||||
|
||||
const {
|
||||
ownedBy: _ownedBy,
|
||||
sharedWithProjects: _sharedWithProjects,
|
||||
oauthTokenData,
|
||||
...data
|
||||
} = credentialData;
|
||||
if (oauthTokenData) {
|
||||
credentialsStore.credentialTestResults.set(id, 'success');
|
||||
return true;
|
||||
}
|
||||
|
||||
return await runCredentialTest({
|
||||
id,
|
||||
name,
|
||||
type,
|
||||
data: data as ICredentialDataDecryptedObject,
|
||||
});
|
||||
} catch {
|
||||
credentialsStore.credentialTestResults.set(id, 'error');
|
||||
credentialTestError.value = genericError();
|
||||
return false;
|
||||
} finally {
|
||||
isTestingCredential.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Resurfaces a failure recorded for this credential by any prior testSavedCredential run. */
|
||||
function restoreStoredError(id: string | null | undefined) {
|
||||
credentialTestError.value =
|
||||
id && credentialsStore.credentialTestResults.get(id) === 'error' ? genericError() : '';
|
||||
}
|
||||
|
||||
return {
|
||||
credentialTestError,
|
||||
isTestingCredential,
|
||||
testCredential,
|
||||
testSavedCredential,
|
||||
restoreStoredError,
|
||||
};
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import type {
|
|||
InstanceAiAdminSettingsUpdateRequest,
|
||||
InstanceAiUserPreferencesResponse,
|
||||
InstanceAiUserPreferencesUpdateRequest,
|
||||
InstanceAiModelCredential,
|
||||
InstanceAiProviderConnection,
|
||||
} from '@n8n/api-types';
|
||||
|
||||
export async function fetchSettings(
|
||||
|
|
@ -34,20 +34,14 @@ export async function updatePreferences(
|
|||
return await makeRestApiRequest(context, 'PUT', '/instance-ai/preferences', body);
|
||||
}
|
||||
|
||||
export async function fetchModelCredentials(
|
||||
context: IRestApiContext,
|
||||
): Promise<InstanceAiModelCredential[]> {
|
||||
return await makeRestApiRequest(context, 'GET', '/instance-ai/settings/credentials');
|
||||
}
|
||||
|
||||
export async function fetchServiceCredentials(
|
||||
context: IRestApiContext,
|
||||
): Promise<InstanceAiModelCredential[]> {
|
||||
): Promise<InstanceAiProviderConnection[]> {
|
||||
return await makeRestApiRequest(context, 'GET', '/instance-ai/settings/service-credentials');
|
||||
}
|
||||
|
||||
export async function fetchInstanceModelCredentials(
|
||||
context: IRestApiContext,
|
||||
): Promise<InstanceAiModelCredential[]> {
|
||||
): Promise<InstanceAiProviderConnection[]> {
|
||||
return await makeRestApiRequest(context, 'GET', '/instance-ai/settings/model-credentials');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ import {
|
|||
updateSettings,
|
||||
fetchPreferences,
|
||||
updatePreferences,
|
||||
fetchModelCredentials,
|
||||
fetchServiceCredentials,
|
||||
fetchInstanceModelCredentials,
|
||||
} from './instanceAi.settings.api';
|
||||
|
|
@ -27,8 +26,7 @@ import type {
|
|||
InstanceAiAdminSettingsResponse,
|
||||
InstanceAiAdminSettingsUpdateRequest,
|
||||
InstanceAiUserPreferencesResponse,
|
||||
InstanceAiUserPreferencesUpdateRequest,
|
||||
InstanceAiModelCredential,
|
||||
InstanceAiProviderConnection,
|
||||
InstanceAiPermissions,
|
||||
InstanceAiPermissionMode,
|
||||
ToolCategory,
|
||||
|
|
@ -50,11 +48,9 @@ export const useInstanceAiSettingsStore = defineStore('instanceAiSettings', () =
|
|||
const isSaving = ref(false);
|
||||
const settings = ref<InstanceAiAdminSettingsResponse | null>(null);
|
||||
const preferences = ref<InstanceAiUserPreferencesResponse | null>(null);
|
||||
const credentials = ref<InstanceAiModelCredential[]>([]);
|
||||
const serviceCredentials = ref<InstanceAiModelCredential[]>([]);
|
||||
const instanceModelCredentials = ref<InstanceAiModelCredential[]>([]);
|
||||
const serviceCredentials = ref<InstanceAiProviderConnection[]>([]);
|
||||
const instanceModelCredentials = ref<InstanceAiProviderConnection[]>([]);
|
||||
const draft = reactive<InstanceAiAdminSettingsUpdateRequest>({});
|
||||
const preferencesDraft = reactive<InstanceAiUserPreferencesUpdateRequest>({});
|
||||
|
||||
// ── Gateway / daemon state ──────────────────────────────────────────
|
||||
const HAS_CONNECTED_STORAGE_KEY = 'instanceAi.gateway.hasConnected';
|
||||
|
|
@ -99,7 +95,6 @@ export const useInstanceAiSettingsStore = defineStore('instanceAiSettings', () =
|
|||
const browserConnectUrl = ref<string | null>(null);
|
||||
const browserConnectUrlExpiresAt = ref<string | null>(null);
|
||||
let browserConnectUrlRequestId = 0;
|
||||
const activeDirectory = computed(() => gatewayDirectory.value);
|
||||
const isInstanceAiDisabled = computed(
|
||||
() => settingsStore.moduleSettings?.['instance-ai']?.enabled !== true,
|
||||
);
|
||||
|
|
@ -125,14 +120,6 @@ export const useInstanceAiSettingsStore = defineStore('instanceAiSettings', () =
|
|||
const isWorkflowBuilderAvailable = computed(
|
||||
() => settingsStore.moduleSettings?.['instance-ai']?.workflowBuilderAvailable ?? true,
|
||||
);
|
||||
const sandboxUnavailableReason = computed(
|
||||
() => settingsStore.moduleSettings?.['instance-ai']?.sandboxUnavailableReason ?? null,
|
||||
);
|
||||
|
||||
const isDirty = computed(() => {
|
||||
if (!settings.value && !preferences.value) return false;
|
||||
return Object.keys(draft).length > 0 || Object.keys(preferencesDraft).length > 0;
|
||||
});
|
||||
|
||||
function syncInstanceAiFlagIntoGlobalModuleSettings(
|
||||
adminRes: InstanceAiAdminSettingsResponse,
|
||||
|
|
@ -162,6 +149,12 @@ export const useInstanceAiSettingsStore = defineStore('instanceAiSettings', () =
|
|||
const canManage = computed(() =>
|
||||
hasPermission(['rbac'], { rbac: { scope: 'instanceAi:manage' } }),
|
||||
);
|
||||
const canManageAiUsage = computed(() =>
|
||||
hasPermission(['rbac'], { rbac: { scope: 'aiAssistant:manage' } }),
|
||||
);
|
||||
const canManageInstanceCredentials = computed(() =>
|
||||
hasPermission(['rbac'], { rbac: { scope: 'credential:manageInstance' } }),
|
||||
);
|
||||
|
||||
async function fetch(): Promise<void> {
|
||||
isLoading.value = true;
|
||||
|
|
@ -176,87 +169,74 @@ export const useInstanceAiSettingsStore = defineStore('instanceAiSettings', () =
|
|||
const [s, p] = await Promise.all(promises);
|
||||
settings.value = s;
|
||||
preferences.value = p;
|
||||
if (!isProxyEnabled.value && !isCloudManaged.value) {
|
||||
const credPromises: [
|
||||
Promise<InstanceAiModelCredential[]>,
|
||||
Promise<InstanceAiModelCredential[]>,
|
||||
Promise<InstanceAiModelCredential[]>,
|
||||
] = [
|
||||
fetchModelCredentials(rootStore.restApiContext),
|
||||
canManage.value ? fetchServiceCredentials(rootStore.restApiContext) : Promise.resolve([]),
|
||||
canManage.value
|
||||
? fetchInstanceModelCredentials(rootStore.restApiContext)
|
||||
: Promise.resolve([]),
|
||||
];
|
||||
const [c, sc, imc] = await Promise.all(credPromises);
|
||||
credentials.value = c;
|
||||
if (!isProxyEnabled.value && !isCloudManaged.value && canManage.value) {
|
||||
const [sc, imc] = await Promise.all([
|
||||
fetchServiceCredentials(rootStore.restApiContext),
|
||||
fetchInstanceModelCredentials(rootStore.restApiContext),
|
||||
]);
|
||||
serviceCredentials.value = sc;
|
||||
instanceModelCredentials.value = imc;
|
||||
}
|
||||
clearDraft();
|
||||
} catch {
|
||||
toast.showError(new Error('Failed to load settings'), 'Settings error');
|
||||
toast.showError(
|
||||
new Error(i18n.baseText('settings.n8nAgent.toast.loadError')),
|
||||
i18n.baseText('settings.n8nAgent.toast.errorTitle'),
|
||||
);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function save(): Promise<void> {
|
||||
/**
|
||||
* Persists the staged admin draft. Returns whether the save succeeded; on
|
||||
* failure the draft is discarded so a later unrelated save can't flush it.
|
||||
*/
|
||||
async function save(): Promise<boolean> {
|
||||
if (Object.keys(draft).length === 0) return true;
|
||||
isSaving.value = true;
|
||||
try {
|
||||
const hasAdminChanges = Object.keys(draft).length > 0;
|
||||
const hasPreferenceChanges = Object.keys(preferencesDraft).length > 0;
|
||||
|
||||
const [adminResult, prefsResult] = await Promise.allSettled([
|
||||
hasAdminChanges
|
||||
? updateSettings(rootStore.restApiContext, {
|
||||
...toRaw(draft),
|
||||
} as InstanceAiAdminSettingsUpdateRequest)
|
||||
: Promise.resolve(settings.value),
|
||||
hasPreferenceChanges
|
||||
? updatePreferences(rootStore.restApiContext, preferencesDraft)
|
||||
: Promise.resolve(preferences.value),
|
||||
]);
|
||||
|
||||
if (adminResult.status === 'fulfilled' && adminResult.value)
|
||||
settings.value = adminResult.value;
|
||||
if (prefsResult.status === 'fulfilled' && prefsResult.value)
|
||||
preferences.value = prefsResult.value;
|
||||
|
||||
const failed = [adminResult, prefsResult].filter((r) => r.status === 'rejected');
|
||||
if (failed.length > 0) {
|
||||
throw (failed[0] as PromiseRejectedResult).reason;
|
||||
}
|
||||
|
||||
const result = await updateSettings(rootStore.restApiContext, {
|
||||
...toRaw(draft),
|
||||
} as InstanceAiAdminSettingsUpdateRequest);
|
||||
settings.value = result;
|
||||
clearDraft();
|
||||
toast.showMessage({ title: 'Settings saved', type: 'success' });
|
||||
if (hasAdminChanges) {
|
||||
await settingsStore.getModuleSettings();
|
||||
const adminSaved =
|
||||
adminResult.status === 'fulfilled' && adminResult.value ? adminResult.value : null;
|
||||
if (adminSaved) {
|
||||
syncInstanceAiFlagIntoGlobalModuleSettings(adminSaved);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
toast.showError(new Error('Failed to save settings'), 'Settings error');
|
||||
toast.showMessage({
|
||||
title: i18n.baseText('settings.n8nAgent.toast.saved'),
|
||||
type: 'success',
|
||||
});
|
||||
syncInstanceAiFlagIntoGlobalModuleSettings(result);
|
||||
await settingsStore.getModuleSettings().catch(() => {});
|
||||
return true;
|
||||
} catch (error) {
|
||||
clearDraft();
|
||||
toast.showError(error, i18n.baseText('settings.n8nAgent.toast.errorTitle'));
|
||||
return false;
|
||||
} finally {
|
||||
isSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Persists only the Instance AI on/off flag (does not send other admin draft fields). */
|
||||
async function persistEnabled(value: boolean): Promise<void> {
|
||||
async function persistEnabled(value: boolean): Promise<boolean> {
|
||||
isSaving.value = true;
|
||||
try {
|
||||
const result = await updateSettings(rootStore.restApiContext, { enabled: value });
|
||||
settings.value = result;
|
||||
delete draft.enabled;
|
||||
await settingsStore.getModuleSettings();
|
||||
syncInstanceAiFlagIntoGlobalModuleSettings(result);
|
||||
toast.showMessage({ title: 'Settings saved', type: 'success' });
|
||||
await settingsStore.getModuleSettings().catch(() => {});
|
||||
toast.showMessage({
|
||||
title: i18n.baseText('settings.n8nAgent.toast.saved'),
|
||||
type: 'success',
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
toast.showError(new Error('Failed to save settings'), 'Settings error');
|
||||
toast.showError(
|
||||
new Error(i18n.baseText('settings.n8nAgent.toast.saveError')),
|
||||
i18n.baseText('settings.n8nAgent.toast.errorTitle'),
|
||||
);
|
||||
return false;
|
||||
} finally {
|
||||
isSaving.value = false;
|
||||
}
|
||||
|
|
@ -269,7 +249,10 @@ export const useInstanceAiSettingsStore = defineStore('instanceAiSettings', () =
|
|||
});
|
||||
preferences.value = result;
|
||||
} catch {
|
||||
toast.showError(new Error('Failed to save preference'), 'Settings error');
|
||||
toast.showError(
|
||||
new Error(i18n.baseText('settings.n8nAgent.toast.preferenceError')),
|
||||
i18n.baseText('settings.n8nAgent.toast.errorTitle'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -363,23 +346,14 @@ export const useInstanceAiSettingsStore = defineStore('instanceAiSettings', () =
|
|||
key: K,
|
||||
value: InstanceAiAdminSettingsUpdateRequest[K],
|
||||
): void {
|
||||
draft[key] = value;
|
||||
}
|
||||
|
||||
function setPreferenceField<K extends keyof InstanceAiUserPreferencesUpdateRequest>(
|
||||
key: K,
|
||||
value: InstanceAiUserPreferencesUpdateRequest[K],
|
||||
): void {
|
||||
preferencesDraft[key] = value;
|
||||
if (value === undefined) delete draft[key];
|
||||
else draft[key] = value;
|
||||
}
|
||||
|
||||
function clearDraft(): void {
|
||||
for (const key of Object.keys(draft)) {
|
||||
delete (draft as Record<string, unknown>)[key];
|
||||
}
|
||||
for (const key of Object.keys(preferencesDraft)) {
|
||||
delete (preferencesDraft as Record<string, unknown>)[key];
|
||||
}
|
||||
}
|
||||
|
||||
function setPermission(key: keyof InstanceAiPermissions, value: InstanceAiPermissionMode): void {
|
||||
|
|
@ -393,10 +367,6 @@ export const useInstanceAiSettingsStore = defineStore('instanceAiSettings', () =
|
|||
return settings.value?.permissions?.[key] ?? 'require_approval';
|
||||
}
|
||||
|
||||
function reset(): void {
|
||||
clearDraft();
|
||||
}
|
||||
|
||||
// ── Gateway status fetch ──────────────────────────────────────────────
|
||||
|
||||
async function fetchGatewayStatus(): Promise<void> {
|
||||
|
|
@ -599,12 +569,7 @@ export const useInstanceAiSettingsStore = defineStore('instanceAiSettings', () =
|
|||
async function refreshCredentials(): Promise<void> {
|
||||
if (isProxyEnabled.value) return;
|
||||
try {
|
||||
const [c, sc] = await Promise.all([
|
||||
fetchModelCredentials(rootStore.restApiContext),
|
||||
fetchServiceCredentials(rootStore.restApiContext),
|
||||
]);
|
||||
credentials.value = c;
|
||||
serviceCredentials.value = sc;
|
||||
serviceCredentials.value = await fetchServiceCredentials(rootStore.restApiContext);
|
||||
} catch {
|
||||
// Silently fail — credentials list will refresh on next full fetch
|
||||
}
|
||||
|
|
@ -633,26 +598,23 @@ export const useInstanceAiSettingsStore = defineStore('instanceAiSettings', () =
|
|||
|
||||
return {
|
||||
canManage,
|
||||
canManageAiUsage,
|
||||
canManageInstanceCredentials,
|
||||
settings,
|
||||
preferences,
|
||||
credentials,
|
||||
serviceCredentials,
|
||||
instanceModelCredentials,
|
||||
draft,
|
||||
preferencesDraft,
|
||||
isLoading,
|
||||
isSaving,
|
||||
isDirty,
|
||||
fetch,
|
||||
save,
|
||||
persistEnabled,
|
||||
persistLocalGatewayPreference,
|
||||
ensurePreferencesLoaded,
|
||||
setField,
|
||||
setPreferenceField,
|
||||
setPermission,
|
||||
getPermission,
|
||||
reset,
|
||||
// Gateway / daemon
|
||||
isDaemonConnecting,
|
||||
setupCommand,
|
||||
|
|
@ -665,7 +627,6 @@ export const useInstanceAiSettingsStore = defineStore('instanceAiSettings', () =
|
|||
gatewayDirectory,
|
||||
gatewayHostIdentifier,
|
||||
gatewayToolCategories,
|
||||
activeDirectory,
|
||||
isInstanceAiDisabled,
|
||||
isLocalGatewayDisabled,
|
||||
isLocalGatewayDisabledByAdmin,
|
||||
|
|
@ -673,7 +634,6 @@ export const useInstanceAiSettingsStore = defineStore('instanceAiSettings', () =
|
|||
isProxyEnabled,
|
||||
isSandboxEnabled,
|
||||
isWorkflowBuilderAvailable,
|
||||
sandboxUnavailableReason,
|
||||
fetchGatewayStatus,
|
||||
connectLocalGateway,
|
||||
isCloudManaged,
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user