mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 21:49:36 +02:00
feat(editor): Move web search to agent capabilities (#38646)
This commit is contained in:
parent
62ff356566
commit
a6ca37cbbf
|
|
@ -7975,9 +7975,11 @@
|
|||
"agents.builder.advanced.webSearch.externalAccess.label": "Live page access",
|
||||
"agents.builder.advanced.webSearch.externalAccess.hint": "Allow OpenAI to fetch live page content",
|
||||
"agents.builder.advanced.webSearch.contextSize.label": "Search context",
|
||||
"agents.builder.advanced.webSearch.method.label": "Provider",
|
||||
"agents.builder.advanced.webSearch.contextSize.hint": "How much space web search results use in the model's context window",
|
||||
"agents.builder.advanced.webSearch.contextSize.low": "Low",
|
||||
"agents.builder.advanced.webSearch.contextSize.medium": "Medium",
|
||||
"agents.builder.advanced.webSearch.contextSize.high": "High",
|
||||
"agents.builder.advanced.webSearch.method.off": "Off",
|
||||
"agents.builder.advanced.webSearch.method.native": "Native",
|
||||
"agents.builder.advanced.webSearch.fallbackProvider.brave": "Brave Search",
|
||||
"agents.builder.advanced.webSearch.fallbackProvider.searxng": "SearXNG",
|
||||
"agents.builder.advanced.webSearch.credential.label": "Credential",
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { nextTick, ref } from 'vue';
|
|||
import type * as VueUse from '@vueuse/core';
|
||||
|
||||
import AgentAdvancedPanel from '../components/AgentAdvancedPanel.vue';
|
||||
import AgentCredentialSelect from '../components/AgentCredentialSelect.vue';
|
||||
import AgentWebSearchSection from '../components/AgentWebSearchSection.vue';
|
||||
import type { ProviderCatalog } from '../composables/useAgentApi';
|
||||
import type { AgentJsonConfig } from '../types';
|
||||
|
||||
|
|
@ -192,50 +192,6 @@ function findStubComponent(wrapper: ReturnType<typeof mount>, testId: string) {
|
|||
};
|
||||
}
|
||||
|
||||
type WebSearchConfig = {
|
||||
enabled: boolean;
|
||||
provider?: string;
|
||||
credential?: string;
|
||||
};
|
||||
|
||||
function getWebSearchConfig(changes: Partial<AgentJsonConfig>): WebSearchConfig | undefined {
|
||||
return (
|
||||
changes.config as
|
||||
| (NonNullable<AgentJsonConfig['config']> & { webSearch?: WebSearchConfig })
|
||||
| undefined
|
||||
)?.webSearch;
|
||||
}
|
||||
|
||||
/** Mounts the panel with the Brave fallback picker visible for `projectId`. */
|
||||
function mountWithFallbackPicker({
|
||||
projectId = 'project-1',
|
||||
disabled = false,
|
||||
}: { projectId?: string; disabled?: boolean } = {}) {
|
||||
return mount(AgentAdvancedPanel, {
|
||||
props: {
|
||||
config: makeConfig({
|
||||
model: 'deepseek/deepseek-chat',
|
||||
config: { webSearch: { enabled: true, provider: 'brave' } },
|
||||
} as Partial<AgentJsonConfig>),
|
||||
projectId,
|
||||
disabled,
|
||||
},
|
||||
global: { stubs: globalStubs },
|
||||
});
|
||||
}
|
||||
|
||||
function findCreateCredentialButton(wrapper: ReturnType<typeof mount>) {
|
||||
return wrapper.find('[data-test-id="node-credentials-select-item-new"]');
|
||||
}
|
||||
|
||||
function getCreateCredentialTooltip(wrapper: ReturnType<typeof mount>) {
|
||||
const tooltip = findCreateCredentialButton(wrapper).element.closest('[data-tooltip-disabled]');
|
||||
return {
|
||||
disabled: tooltip?.getAttribute('data-tooltip-disabled'),
|
||||
content: tooltip?.getAttribute('data-tooltip-content'),
|
||||
};
|
||||
}
|
||||
|
||||
describe('AgentAdvancedPanel', () => {
|
||||
beforeEach(() => {
|
||||
ensureLoadedMock.mockReset();
|
||||
|
|
@ -246,346 +202,27 @@ describe('AgentAdvancedPanel', () => {
|
|||
projectsStoreState.myProjects = [];
|
||||
});
|
||||
|
||||
it('renders both advanced sections without a collapsible heading', () => {
|
||||
it('renders the advanced section without a collapsible heading', () => {
|
||||
const wrapper = mount(AgentAdvancedPanel, {
|
||||
props: { config: makeConfig() },
|
||||
global: { stubs: globalStubs },
|
||||
});
|
||||
|
||||
expect(wrapper.text()).toContain('agents.builder.advanced.webSearch.label');
|
||||
expect(wrapper.text()).toContain('agents.builder.advanced.title');
|
||||
expect(wrapper.find('[data-testid="agent-advanced-trigger"]').exists()).toBe(false);
|
||||
expect(wrapper.find('[data-testid="agent-advanced-chevron"]').exists()).toBe(false);
|
||||
expect(wrapper.find('[data-testid="agent-advanced-content"]').isVisible()).toBe(true);
|
||||
expect(wrapper.find('[data-testid="agent-behavior-panel"]').isVisible()).toBe(true);
|
||||
expect(wrapper.find('[data-testid="agent-reasoning-toggle"]').isVisible()).toBe(true);
|
||||
});
|
||||
|
||||
it('treats sparse native web search config as disabled', async () => {
|
||||
it('does not render web search controls', function doesNotRenderWebSearchControls() {
|
||||
const wrapper = mount(AgentAdvancedPanel, {
|
||||
props: { config: makeConfig() },
|
||||
global: { stubs: globalStubs },
|
||||
});
|
||||
|
||||
const method = findStubComponent(wrapper, 'agent-web-search-method');
|
||||
expect(method.exists()).toBe(true);
|
||||
expect(method.props('modelValue')).toBe('off');
|
||||
|
||||
emitSelectValue(wrapper, 'agent-web-search-method', 'native');
|
||||
await nextTick();
|
||||
const events = wrapper.emitted('update:config') ?? [];
|
||||
const last = events[events.length - 1][0] as Partial<AgentJsonConfig>;
|
||||
expect(getWebSearchConfig(last)).toEqual({ enabled: true, provider: 'native' });
|
||||
expect(last.providerTools).toEqual({ 'anthropic.web_search': { maxUses: 5 } });
|
||||
});
|
||||
|
||||
it('emits provider-specific web search options', async () => {
|
||||
const config = makeConfig({
|
||||
model: 'openai/gpt-5',
|
||||
config: { webSearch: { enabled: true } },
|
||||
providerTools: { 'openai.web_search': {} },
|
||||
} as Partial<AgentJsonConfig>);
|
||||
const wrapper = mount(AgentAdvancedPanel, {
|
||||
props: { config },
|
||||
global: { stubs: globalStubs },
|
||||
});
|
||||
|
||||
await wrapper.find('[data-testid="agent-web-search-external-access"]').trigger('click');
|
||||
|
||||
const events = wrapper.emitted('update:config') ?? [];
|
||||
const last = events[events.length - 1][0] as Partial<AgentJsonConfig>;
|
||||
expect(last.providerTools).toEqual({
|
||||
'openai.web_search': {
|
||||
externalWebAccess: false,
|
||||
searchContextSize: 'medium',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('strips native web search provider tools when native web search is disabled', async () => {
|
||||
const config = makeConfig({
|
||||
config: { webSearch: { enabled: true } },
|
||||
providerTools: {
|
||||
'anthropic.web_search': { maxUses: 5 },
|
||||
'openai.image_generation': {},
|
||||
},
|
||||
} as Partial<AgentJsonConfig>);
|
||||
const wrapper = mount(AgentAdvancedPanel, {
|
||||
props: { config },
|
||||
global: { stubs: globalStubs },
|
||||
});
|
||||
|
||||
emitSelectValue(wrapper, 'agent-web-search-method', 'off');
|
||||
await nextTick();
|
||||
|
||||
const events = wrapper.emitted('update:config') ?? [];
|
||||
const last = events[events.length - 1][0] as Partial<AgentJsonConfig>;
|
||||
expect(getWebSearchConfig(last)).toEqual({ enabled: false });
|
||||
expect(last.providerTools).toEqual({ 'openai.image_generation': {} });
|
||||
});
|
||||
|
||||
it('enables fallback web search for providers without native web search', async () => {
|
||||
const config = makeConfig({ model: 'deepseek/deepseek-chat' });
|
||||
const wrapper = mount(AgentAdvancedPanel, {
|
||||
props: { config },
|
||||
global: { stubs: globalStubs },
|
||||
});
|
||||
|
||||
emitSelectValue(wrapper, 'agent-web-search-method', 'brave');
|
||||
await nextTick();
|
||||
|
||||
const events = wrapper.emitted('update:config') ?? [];
|
||||
const last = events[events.length - 1][0] as Partial<AgentJsonConfig>;
|
||||
expect(getWebSearchConfig(last)).toEqual({ enabled: true, provider: 'brave' });
|
||||
});
|
||||
|
||||
it('keeps fallback controls visible on native-capable models', async () => {
|
||||
const config = makeConfig({
|
||||
config: { webSearch: { enabled: true, provider: 'brave', credential: 'brave-1' } },
|
||||
providerTools: { 'anthropic.web_search': { maxUses: 5 } },
|
||||
} as Partial<AgentJsonConfig>);
|
||||
const wrapper = mount(AgentAdvancedPanel, {
|
||||
props: { config },
|
||||
global: { stubs: globalStubs },
|
||||
});
|
||||
|
||||
expect(wrapper.find('[data-testid="agent-web-search-method"]').exists()).toBe(true);
|
||||
expect(wrapper.find('[data-test-id="agent-web-search-fallback-credential"]').exists()).toBe(
|
||||
true,
|
||||
);
|
||||
expect(wrapper.find('[data-testid="agent-web-search-max-uses"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['brave', 'braveSearchApi'],
|
||||
['searxng', 'searXngApi'],
|
||||
] as const)(
|
||||
"creating a credential from the fallback picker opens the credential modal for the provider's type in the panel's project and selects the created credential",
|
||||
async (provider, credentialType) => {
|
||||
const wrapper = mount(AgentAdvancedPanel, {
|
||||
props: {
|
||||
config: makeConfig({
|
||||
model: 'deepseek/deepseek-chat',
|
||||
config: { webSearch: { enabled: true, provider } },
|
||||
} as Partial<AgentJsonConfig>),
|
||||
projectId: 'project-1',
|
||||
},
|
||||
global: { stubs: globalStubs },
|
||||
});
|
||||
|
||||
wrapper.findComponent(AgentCredentialSelect).vm.$emit('create');
|
||||
|
||||
expect(openNewCredentialMock).toHaveBeenCalledWith(
|
||||
credentialType,
|
||||
false,
|
||||
false,
|
||||
'project-1',
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
expect.objectContaining({ hideAskAssistant: true }),
|
||||
);
|
||||
|
||||
const onCredentialCreated = openNewCredentialMock.mock.calls.at(-1)?.[7]?.onCredentialCreated;
|
||||
expect(onCredentialCreated).toBeTypeOf('function');
|
||||
onCredentialCreated?.({ id: 'new-cred' });
|
||||
|
||||
const last = wrapper.emitted('update:config')?.at(-1)?.[0] as Partial<AgentJsonConfig>;
|
||||
expect(getWebSearchConfig(last)).toEqual({
|
||||
enabled: true,
|
||||
provider,
|
||||
credential: 'new-cred',
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it('offers an enabled "Create new credential" action without the permission tooltip when the user can create credentials in the project', () => {
|
||||
const wrapper = mountWithFallbackPicker();
|
||||
|
||||
const createButton = findCreateCredentialButton(wrapper);
|
||||
expect(createButton.text()).toBe('Create new credential');
|
||||
expect(createButton.attributes()).not.toHaveProperty('disabled');
|
||||
expect(getCreateCredentialTooltip(wrapper).disabled).toBe('true');
|
||||
});
|
||||
|
||||
it('disables "Create new credential" with the permission tooltip and does not open the credential modal when the user cannot create credentials in the project', () => {
|
||||
projectsStoreState.currentProject = { id: 'project-1', scopes: ['credential:read'] };
|
||||
|
||||
const wrapper = mountWithFallbackPicker();
|
||||
|
||||
expect(
|
||||
wrapper.findComponent(AgentCredentialSelect).props('credentialPermissions'),
|
||||
).toMatchObject({ create: false });
|
||||
expect(findCreateCredentialButton(wrapper).attributes()).toHaveProperty('disabled');
|
||||
expect(getCreateCredentialTooltip(wrapper)).toEqual({
|
||||
disabled: 'false',
|
||||
content: 'Your current role does not allow you to create credentials',
|
||||
});
|
||||
|
||||
// Even if the dropdown emits `create` anyway, the panel must not open the modal.
|
||||
wrapper.findComponent(AgentCredentialSelect).vm.$emit('create');
|
||||
expect(openNewCredentialMock).not.toHaveBeenCalled();
|
||||
expect(wrapper.emitted('update:config')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not open the credential modal from the fallback picker while the panel is disabled', () => {
|
||||
const wrapper = mountWithFallbackPicker({ disabled: true });
|
||||
|
||||
wrapper.findComponent(AgentCredentialSelect).vm.$emit('create');
|
||||
|
||||
expect(openNewCredentialMock).not.toHaveBeenCalled();
|
||||
expect(wrapper.emitted('update:config')).toBeUndefined();
|
||||
});
|
||||
|
||||
describe('resolving the project whose scopes decide whether a credential can be created', () => {
|
||||
const canCreate = CREDENTIAL_CREATE_SCOPES;
|
||||
const cannotCreate = ['credential:read'];
|
||||
|
||||
it.each<{
|
||||
name: string;
|
||||
projectId: string;
|
||||
currentProject: MockProject;
|
||||
personalProject: MockProject;
|
||||
myProjects: MockProject[];
|
||||
expectedCreate: boolean;
|
||||
}>([
|
||||
{
|
||||
name: 'uses the current project when it matches the panel project',
|
||||
projectId: 'project-1',
|
||||
currentProject: { id: 'project-1', scopes: canCreate },
|
||||
personalProject: { id: 'project-2', scopes: cannotCreate },
|
||||
myProjects: [{ id: 'project-3', scopes: cannotCreate }],
|
||||
expectedCreate: true,
|
||||
},
|
||||
{
|
||||
name: 'falls back to the personal project when the current project does not match',
|
||||
projectId: 'project-2',
|
||||
currentProject: { id: 'project-1', scopes: cannotCreate },
|
||||
personalProject: { id: 'project-2', scopes: canCreate },
|
||||
myProjects: [{ id: 'project-3', scopes: cannotCreate }],
|
||||
expectedCreate: true,
|
||||
},
|
||||
{
|
||||
name: 'falls back to the matching entry in myProjects when neither the current nor the personal project match',
|
||||
projectId: 'project-3',
|
||||
currentProject: { id: 'project-1', scopes: cannotCreate },
|
||||
personalProject: { id: 'project-2', scopes: cannotCreate },
|
||||
myProjects: [{ id: 'project-3', scopes: canCreate }],
|
||||
expectedCreate: true,
|
||||
},
|
||||
{
|
||||
name: 'denies creating credentials when no known project matches the panel project',
|
||||
projectId: 'project-unknown',
|
||||
currentProject: { id: 'project-1', scopes: canCreate },
|
||||
personalProject: { id: 'project-2', scopes: canCreate },
|
||||
myProjects: [{ id: 'project-3', scopes: canCreate }],
|
||||
expectedCreate: false,
|
||||
},
|
||||
])('$name', ({ projectId, currentProject, personalProject, myProjects, expectedCreate }) => {
|
||||
projectsStoreState.currentProject = currentProject;
|
||||
projectsStoreState.personalProject = personalProject;
|
||||
projectsStoreState.myProjects = myProjects;
|
||||
|
||||
const wrapper = mountWithFallbackPicker({ projectId });
|
||||
|
||||
expect(
|
||||
wrapper.findComponent(AgentCredentialSelect).props('credentialPermissions'),
|
||||
).toMatchObject({ create: expectedCreate });
|
||||
|
||||
wrapper.findComponent(AgentCredentialSelect).vm.$emit('create');
|
||||
if (expectedCreate) {
|
||||
expect(openNewCredentialMock).toHaveBeenCalledWith(
|
||||
'braveSearchApi',
|
||||
false,
|
||||
false,
|
||||
projectId,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
expect.objectContaining({ hideAskAssistant: true }),
|
||||
);
|
||||
} else {
|
||||
expect(openNewCredentialMock).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('switches fallback web search to native and emits native provider tools', async () => {
|
||||
const config = makeConfig({
|
||||
config: { webSearch: { enabled: true, provider: 'brave', credential: 'brave-1' } },
|
||||
} as Partial<AgentJsonConfig>);
|
||||
const wrapper = mount(AgentAdvancedPanel, {
|
||||
props: { config },
|
||||
global: { stubs: globalStubs },
|
||||
});
|
||||
|
||||
emitSelectValue(wrapper, 'agent-web-search-method', 'native');
|
||||
await nextTick();
|
||||
|
||||
const events = wrapper.emitted('update:config') ?? [];
|
||||
const last = events[events.length - 1][0] as Partial<AgentJsonConfig>;
|
||||
expect(getWebSearchConfig(last)).toEqual({ enabled: true, provider: 'native' });
|
||||
expect(last.providerTools).toEqual({ 'anthropic.web_search': { maxUses: 5 } });
|
||||
});
|
||||
|
||||
it('preserves fallback web search credential when switching away and back to the same fallback provider', async () => {
|
||||
const config = makeConfig({
|
||||
config: { webSearch: { enabled: true, provider: 'brave', credential: 'brave-1' } },
|
||||
} as Partial<AgentJsonConfig>);
|
||||
const wrapper = mount(AgentAdvancedPanel, {
|
||||
props: { config },
|
||||
global: { stubs: globalStubs },
|
||||
});
|
||||
|
||||
emitSelectValue(wrapper, 'agent-web-search-method', 'native');
|
||||
await nextTick();
|
||||
emitSelectValue(wrapper, 'agent-web-search-method', 'brave');
|
||||
await nextTick();
|
||||
|
||||
const events = wrapper.emitted('update:config') ?? [];
|
||||
const last = events[events.length - 1][0] as Partial<AgentJsonConfig>;
|
||||
expect(getWebSearchConfig(last)).toEqual({
|
||||
enabled: true,
|
||||
provider: 'brave',
|
||||
credential: 'brave-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('clears fallback web search credential when switching fallback providers', async () => {
|
||||
const config = makeConfig({
|
||||
config: { webSearch: { enabled: true, provider: 'brave', credential: 'brave-1' } },
|
||||
} as Partial<AgentJsonConfig>);
|
||||
const wrapper = mount(AgentAdvancedPanel, {
|
||||
props: { config },
|
||||
global: { stubs: globalStubs },
|
||||
});
|
||||
|
||||
emitSelectValue(wrapper, 'agent-web-search-method', 'searxng');
|
||||
await nextTick();
|
||||
|
||||
const events = wrapper.emitted('update:config') ?? [];
|
||||
const last = events[events.length - 1][0] as Partial<AgentJsonConfig>;
|
||||
expect(getWebSearchConfig(last)).toEqual({ enabled: true, provider: 'searxng' });
|
||||
});
|
||||
|
||||
it('switches native web search to fallback and strips native provider tools', async () => {
|
||||
const config = makeConfig({
|
||||
config: { webSearch: { enabled: true, provider: 'native' } },
|
||||
providerTools: {
|
||||
'anthropic.web_search': { maxUses: 5 },
|
||||
'openai.image_generation': {},
|
||||
},
|
||||
} as Partial<AgentJsonConfig>);
|
||||
const wrapper = mount(AgentAdvancedPanel, {
|
||||
props: { config },
|
||||
global: { stubs: globalStubs },
|
||||
});
|
||||
|
||||
emitSelectValue(wrapper, 'agent-web-search-method', 'brave');
|
||||
await nextTick();
|
||||
|
||||
const events = wrapper.emitted('update:config') ?? [];
|
||||
const last = events[events.length - 1][0] as Partial<AgentJsonConfig>;
|
||||
expect(getWebSearchConfig(last)).toEqual({ enabled: true, provider: 'brave' });
|
||||
expect(last.providerTools).toEqual({ 'openai.image_generation': {} });
|
||||
expect(wrapper.findComponent(AgentWebSearchSection).exists()).toBe(false);
|
||||
expect(wrapper.find('[data-testid="agent-web-search-method"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('loads the model catalog for the current project', () => {
|
||||
|
|
@ -880,8 +517,6 @@ describe('AgentAdvancedPanel', () => {
|
|||
props: { config, disabled: true },
|
||||
global: { stubs: globalStubs },
|
||||
});
|
||||
const webSearchMethod = findStubComponent(wrapper, 'agent-web-search-method');
|
||||
expect(webSearchMethod.props('disabled')).toBe(true);
|
||||
expect(
|
||||
wrapper.find('[data-testid="agent-reasoning-toggle"]').attributes('disabled'),
|
||||
).toBeDefined();
|
||||
|
|
|
|||
|
|
@ -69,6 +69,14 @@ vi.mock('@n8n/i18n', () => ({
|
|||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../components/AgentWebSearchSection.vue', () => ({
|
||||
default: {
|
||||
name: 'AgentWebSearchSection',
|
||||
emits: ['update:config'],
|
||||
template: '<div data-testid="agent-web-search-section" />',
|
||||
},
|
||||
}));
|
||||
|
||||
function mountSection(
|
||||
tools: AgentJsonToolRef[],
|
||||
customTools: Record<string, CustomToolEntry> = {},
|
||||
|
|
@ -158,6 +166,16 @@ describe('AgentCapabilitiesSection', () => {
|
|||
integrationsCatalogRef.value = [];
|
||||
});
|
||||
|
||||
it('renders web search and forwards its config updates', () => {
|
||||
const wrapper = mountSection([]);
|
||||
const webSearchSection = wrapper.getComponent({ name: 'AgentWebSearchSection' });
|
||||
const update = { config: { webSearch: { enabled: false } } };
|
||||
|
||||
webSearchSection.vm.$emit('update:config', update);
|
||||
|
||||
expect(wrapper.emitted('update:config')?.[0]).toEqual([update]);
|
||||
});
|
||||
|
||||
it('formats node and custom tool chip labels for display', () => {
|
||||
getNodeType.mockReturnValue(null);
|
||||
|
||||
|
|
@ -997,42 +1015,17 @@ describe('AgentCapabilitiesSection', () => {
|
|||
});
|
||||
|
||||
it('renders only the allowlisted sections and skips sub-agents', async () => {
|
||||
const wrapper = mount(AgentCapabilitiesSection, {
|
||||
props: {
|
||||
config: null,
|
||||
tools: [],
|
||||
customTools: {},
|
||||
skills: [],
|
||||
projectId: 'project-id',
|
||||
agentId: 'agent-id',
|
||||
isPublished: false,
|
||||
sections: ['tools', 'skills'],
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
NodeIcon: { template: '<span />' },
|
||||
N8nButton: {
|
||||
props: ['disabled'],
|
||||
emits: ['click'],
|
||||
template:
|
||||
'<button v-bind="$attrs" :disabled="disabled" @click="$emit(\'click\')"><slot name="icon" /><slot /></button>',
|
||||
},
|
||||
N8nIcon: { template: '<span />' },
|
||||
N8nText: { template: '<span><slot /></span>' },
|
||||
N8nTooltip: { template: '<span><slot /></span>' },
|
||||
},
|
||||
},
|
||||
});
|
||||
const wrapper = mountSection([], {}, null, [], [], { sections: ['tools', 'skills'] });
|
||||
await flushPromises();
|
||||
|
||||
// Allowlisted rows present.
|
||||
/** Allowlisted rows are present. */
|
||||
expect(wrapper.find('[data-testid="agent-capabilities-add-tool"]').exists()).toBe(true);
|
||||
expect(wrapper.find('[data-testid="agent-capabilities-add-skill"]').exists()).toBe(true);
|
||||
|
||||
// Suppressed rows absent.
|
||||
/** Suppressed rows are absent. */
|
||||
expect(wrapper.find('[data-testid="agent-capabilities-add-sub-agent"]').exists()).toBe(false);
|
||||
|
||||
// The project-agents list (only needed for sub-agents) is not fetched.
|
||||
/** The project-agent list is not needed for hidden sub-agents. */
|
||||
expect(ensureProjectAgentsLoadedSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,559 @@
|
|||
/* eslint-disable import-x/no-extraneous-dependencies -- test-only pattern */
|
||||
import { beforeEach, describe, it, expect, vi } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { nextTick } from 'vue';
|
||||
|
||||
import AgentWebSearchSection from '../components/AgentWebSearchSection.vue';
|
||||
import AgentCredentialSelect from '../components/AgentCredentialSelect.vue';
|
||||
import type { AgentJsonConfig } from '../types';
|
||||
|
||||
const openNewCredentialMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
type MockProject = { id: string; scopes: string[] };
|
||||
const CREDENTIAL_CREATE_SCOPES = ['credential:create'];
|
||||
// Mutable per test (reset in beforeEach) so the project → permission resolution can be exercised.
|
||||
const projectsStoreState = vi.hoisted(() => ({
|
||||
currentProject: null as MockProject | null,
|
||||
personalProject: null as MockProject | null,
|
||||
myProjects: [] as MockProject[],
|
||||
}));
|
||||
|
||||
vi.mock('@n8n/i18n', () => ({
|
||||
useI18n: () => ({
|
||||
baseText: (key: string) =>
|
||||
({
|
||||
'agents.builder.advanced.reasoning.hint': 'Let the model reason before responding.',
|
||||
'agents.builder.advanced.reasoning.unsupportedHint':
|
||||
'This model does not support reasoning',
|
||||
'agents.builder.advanced.reasoning.noModelHint': 'No model selected',
|
||||
'nodeCredentials.createNew': 'Create new credential',
|
||||
'nodeCredentials.createNew.permissionDenied':
|
||||
'Your current role does not allow you to create credentials',
|
||||
})[key] ?? key,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/features/credentials/credentials.store', () => ({
|
||||
useCredentialsStore: () => ({
|
||||
allCredentials: [
|
||||
{ id: 'brave-1', name: 'Brave Key', type: 'braveSearchApi' },
|
||||
{ id: 'searxng-1', name: 'SearXNG', type: 'searXngApi' },
|
||||
],
|
||||
getCredentialTypeByName: (type: string) => ({ displayName: type }),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/features/collaboration/projects/projects.store', () => ({
|
||||
useProjectsStore: () => projectsStoreState,
|
||||
}));
|
||||
|
||||
vi.mock('@/app/stores/ui.store', () => ({
|
||||
useUIStore: () => ({ openNewCredential: openNewCredentialMock }),
|
||||
}));
|
||||
|
||||
vi.mock('@n8n/design-system', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@n8n/design-system')>();
|
||||
return {
|
||||
...actual,
|
||||
N8nDropdownMenu: {
|
||||
name: 'N8nDropdownMenu',
|
||||
props: ['items', 'disabled', 'placement'],
|
||||
emits: ['select'],
|
||||
template: '<div v-bind="$attrs"><slot name="trigger" /></div>',
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const globalStubs = {
|
||||
N8nIcon: { template: '<span v-bind="$attrs" />', props: ['icon', 'size'] },
|
||||
N8nText: { template: '<span><slot /></span>' },
|
||||
N8nInputNumber: {
|
||||
props: ['modelValue', 'disabled', 'min', 'max', 'precision', 'placeholder'],
|
||||
emits: ['update:modelValue'],
|
||||
template:
|
||||
'<input :value="modelValue" :disabled="disabled" @input="$emit(\'update:modelValue\', Number($event.target.value))" />',
|
||||
},
|
||||
/** Keep the select as one root and render its credential creation footer. */
|
||||
N8nSelect: {
|
||||
props: ['modelValue', 'disabled'],
|
||||
emits: ['update:modelValue'],
|
||||
template:
|
||||
'<select v-bind="$attrs" :value="modelValue" :disabled="disabled" @change="$emit(\'update:modelValue\', $event.target.value)"><slot /><slot name="footer" /></select>',
|
||||
},
|
||||
N8nTooltip: {
|
||||
props: ['disabled', 'content'],
|
||||
template:
|
||||
'<span :data-tooltip-disabled="String(disabled)" :data-tooltip-content="content"><slot /></span>',
|
||||
},
|
||||
N8nOption: {
|
||||
name: 'N8nOption',
|
||||
props: ['value', 'label'],
|
||||
template: '<option :value="value">{{ label }}</option>',
|
||||
},
|
||||
Option: {
|
||||
props: ['value', 'label'],
|
||||
template: '<option :value="value">{{ label }}</option>',
|
||||
},
|
||||
N8nSwitch2: {
|
||||
props: ['modelValue', 'disabled'],
|
||||
emits: ['update:modelValue'],
|
||||
template:
|
||||
'<button :disabled="disabled" :data-checked="modelValue" @click="$emit(\'update:modelValue\', !modelValue)" />',
|
||||
},
|
||||
N8nSegmentControl: {
|
||||
props: ['modelValue', 'options', 'disabled', 'size'],
|
||||
emits: ['update:modelValue'],
|
||||
template: '<div v-bind="$attrs" />',
|
||||
},
|
||||
};
|
||||
|
||||
function makeConfig(overrides: Partial<AgentJsonConfig> = {}): AgentJsonConfig {
|
||||
return {
|
||||
name: 'A',
|
||||
instructions: 'i',
|
||||
model: 'anthropic/claude-sonnet-4-6',
|
||||
credential: 'c',
|
||||
...overrides,
|
||||
} as AgentJsonConfig;
|
||||
}
|
||||
|
||||
function emitSelectValue(wrapper: ReturnType<typeof mount>, testId: string, value: string) {
|
||||
const select = wrapper.findComponent(`[data-testid="${testId}"]`) as unknown as {
|
||||
vm: { $emit: (event: 'select', value: string) => void };
|
||||
};
|
||||
select.vm.$emit('select', value);
|
||||
}
|
||||
|
||||
function findStubComponent(wrapper: ReturnType<typeof mount>, testId: string) {
|
||||
return wrapper.findComponent(`[data-testid="${testId}"]`) as unknown as {
|
||||
exists: () => boolean;
|
||||
props: (name: string) => unknown;
|
||||
vm: { $emit: (event: 'update:modelValue', value: string) => void };
|
||||
};
|
||||
}
|
||||
|
||||
type WebSearchConfig = {
|
||||
enabled: boolean;
|
||||
provider?: string;
|
||||
credential?: string;
|
||||
};
|
||||
|
||||
function getWebSearchConfig(changes: Partial<AgentJsonConfig>): WebSearchConfig | undefined {
|
||||
return (
|
||||
changes.config as
|
||||
| (NonNullable<AgentJsonConfig['config']> & { webSearch?: WebSearchConfig })
|
||||
| undefined
|
||||
)?.webSearch;
|
||||
}
|
||||
|
||||
/** Mounts the panel with the Brave fallback picker visible for `projectId`. */
|
||||
function mountWithFallbackPicker({
|
||||
projectId = 'project-1',
|
||||
disabled = false,
|
||||
}: { projectId?: string; disabled?: boolean } = {}) {
|
||||
return mount(AgentWebSearchSection, {
|
||||
props: {
|
||||
config: makeConfig({
|
||||
model: 'deepseek/deepseek-chat',
|
||||
config: { webSearch: { enabled: true, provider: 'brave' } },
|
||||
} as Partial<AgentJsonConfig>),
|
||||
projectId,
|
||||
disabled,
|
||||
},
|
||||
global: { stubs: globalStubs },
|
||||
});
|
||||
}
|
||||
|
||||
function findCreateCredentialButton(wrapper: ReturnType<typeof mount>) {
|
||||
return wrapper.find('[data-test-id="node-credentials-select-item-new"]');
|
||||
}
|
||||
|
||||
function getCreateCredentialTooltip(wrapper: ReturnType<typeof mount>) {
|
||||
const tooltip = findCreateCredentialButton(wrapper).element.closest('[data-tooltip-disabled]');
|
||||
return {
|
||||
disabled: tooltip?.getAttribute('data-tooltip-disabled'),
|
||||
content: tooltip?.getAttribute('data-tooltip-content'),
|
||||
};
|
||||
}
|
||||
|
||||
describe('AgentWebSearchSection', () => {
|
||||
beforeEach(() => {
|
||||
openNewCredentialMock.mockReset();
|
||||
projectsStoreState.currentProject = { id: 'project-1', scopes: CREDENTIAL_CREATE_SCOPES };
|
||||
projectsStoreState.personalProject = null;
|
||||
projectsStoreState.myProjects = [];
|
||||
});
|
||||
|
||||
it('treats sparse native web search config as disabled', async () => {
|
||||
const wrapper = mount(AgentWebSearchSection, {
|
||||
props: { config: makeConfig() },
|
||||
global: { stubs: globalStubs },
|
||||
});
|
||||
|
||||
const method = findStubComponent(wrapper, 'agent-web-search-method');
|
||||
expect(method.exists()).toBe(true);
|
||||
expect(method.props('items')).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ id: 'off', checked: true })]),
|
||||
);
|
||||
|
||||
emitSelectValue(wrapper, 'agent-web-search-method', 'native');
|
||||
await nextTick();
|
||||
const events = wrapper.emitted('update:config') ?? [];
|
||||
const last = events[events.length - 1][0] as Partial<AgentJsonConfig>;
|
||||
expect(getWebSearchConfig(last)).toEqual({ enabled: true, provider: 'native' });
|
||||
expect(last.providerTools).toEqual({ 'anthropic.web_search': { maxUses: 5 } });
|
||||
});
|
||||
|
||||
it('emits provider-specific web search options', async () => {
|
||||
const config = makeConfig({
|
||||
model: 'openai/gpt-5',
|
||||
config: { webSearch: { enabled: true } },
|
||||
providerTools: { 'openai.web_search': {} },
|
||||
} as Partial<AgentJsonConfig>);
|
||||
const wrapper = mount(AgentWebSearchSection, {
|
||||
props: { config },
|
||||
global: { stubs: globalStubs },
|
||||
});
|
||||
|
||||
await wrapper.find('[data-testid="agent-web-search-external-access"]').trigger('click');
|
||||
|
||||
const events = wrapper.emitted('update:config') ?? [];
|
||||
const last = events[events.length - 1][0] as Partial<AgentJsonConfig>;
|
||||
expect(last.providerTools).toEqual({
|
||||
'openai.web_search': {
|
||||
externalWebAccess: false,
|
||||
searchContextSize: 'medium',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it.each(['low', 'medium', 'high'] as const)(
|
||||
'emits the %s search context size and offers all context size options',
|
||||
async function emitsSearchContextSize(contextSize) {
|
||||
const config = makeConfig({
|
||||
model: 'openai/gpt-5',
|
||||
config: { webSearch: { enabled: true } },
|
||||
providerTools: { 'openai.web_search': {} },
|
||||
} as Partial<AgentJsonConfig>);
|
||||
const wrapper = mount(AgentWebSearchSection, {
|
||||
props: { config },
|
||||
global: { stubs: globalStubs },
|
||||
});
|
||||
const contextSizeControl = wrapper.findComponent({ name: 'SegmentControl' });
|
||||
|
||||
expect(contextSizeControl.props('options')).toEqual([
|
||||
{
|
||||
label: 'agents.builder.advanced.webSearch.contextSize.low',
|
||||
value: 'low',
|
||||
},
|
||||
{
|
||||
label: 'agents.builder.advanced.webSearch.contextSize.medium',
|
||||
value: 'medium',
|
||||
},
|
||||
{
|
||||
label: 'agents.builder.advanced.webSearch.contextSize.high',
|
||||
value: 'high',
|
||||
},
|
||||
]);
|
||||
|
||||
contextSizeControl.vm.$emit('update:modelValue', contextSize);
|
||||
await nextTick();
|
||||
|
||||
const last = wrapper.emitted('update:config')?.at(-1)?.[0] as Partial<AgentJsonConfig>;
|
||||
expect(last.providerTools).toEqual({
|
||||
'openai.web_search': {
|
||||
externalWebAccess: true,
|
||||
searchContextSize: contextSize,
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it('strips native web search provider tools when native web search is disabled', async () => {
|
||||
const config = makeConfig({
|
||||
config: { webSearch: { enabled: true } },
|
||||
providerTools: {
|
||||
'anthropic.web_search': { maxUses: 5 },
|
||||
'openai.image_generation': {},
|
||||
},
|
||||
} as Partial<AgentJsonConfig>);
|
||||
const wrapper = mount(AgentWebSearchSection, {
|
||||
props: { config },
|
||||
global: { stubs: globalStubs },
|
||||
});
|
||||
|
||||
emitSelectValue(wrapper, 'agent-web-search-method', 'off');
|
||||
await nextTick();
|
||||
|
||||
const events = wrapper.emitted('update:config') ?? [];
|
||||
const last = events[events.length - 1][0] as Partial<AgentJsonConfig>;
|
||||
expect(getWebSearchConfig(last)).toEqual({ enabled: false });
|
||||
expect(last.providerTools).toEqual({ 'openai.image_generation': {} });
|
||||
});
|
||||
|
||||
it('enables fallback web search for providers without native web search', async () => {
|
||||
const config = makeConfig({ model: 'deepseek/deepseek-chat' });
|
||||
const wrapper = mount(AgentWebSearchSection, {
|
||||
props: { config },
|
||||
global: { stubs: globalStubs },
|
||||
});
|
||||
|
||||
emitSelectValue(wrapper, 'agent-web-search-method', 'brave');
|
||||
await nextTick();
|
||||
|
||||
const events = wrapper.emitted('update:config') ?? [];
|
||||
const last = events[events.length - 1][0] as Partial<AgentJsonConfig>;
|
||||
expect(getWebSearchConfig(last)).toEqual({ enabled: true, provider: 'brave' });
|
||||
});
|
||||
|
||||
it('keeps fallback controls visible on native-capable models', async () => {
|
||||
const config = makeConfig({
|
||||
config: { webSearch: { enabled: true, provider: 'brave', credential: 'brave-1' } },
|
||||
providerTools: { 'anthropic.web_search': { maxUses: 5 } },
|
||||
} as Partial<AgentJsonConfig>);
|
||||
const wrapper = mount(AgentWebSearchSection, {
|
||||
props: { config },
|
||||
global: { stubs: globalStubs },
|
||||
});
|
||||
|
||||
expect(wrapper.find('[data-testid="agent-web-search-method"]').exists()).toBe(true);
|
||||
expect(wrapper.find('[data-test-id="agent-web-search-fallback-credential"]').exists()).toBe(
|
||||
true,
|
||||
);
|
||||
expect(wrapper.find('[data-testid="agent-web-search-max-uses"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['brave', 'braveSearchApi'],
|
||||
['searxng', 'searXngApi'],
|
||||
] as const)(
|
||||
"creating a credential from the fallback picker opens the credential modal for the provider's type in the panel's project and selects the created credential",
|
||||
async (provider, credentialType) => {
|
||||
const wrapper = mount(AgentWebSearchSection, {
|
||||
props: {
|
||||
config: makeConfig({
|
||||
model: 'deepseek/deepseek-chat',
|
||||
config: { webSearch: { enabled: true, provider } },
|
||||
} as Partial<AgentJsonConfig>),
|
||||
projectId: 'project-1',
|
||||
},
|
||||
global: { stubs: globalStubs },
|
||||
});
|
||||
|
||||
wrapper.findComponent(AgentCredentialSelect).vm.$emit('create');
|
||||
|
||||
expect(openNewCredentialMock).toHaveBeenCalledWith(
|
||||
credentialType,
|
||||
false,
|
||||
false,
|
||||
'project-1',
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
expect.objectContaining({ hideAskAssistant: true }),
|
||||
);
|
||||
|
||||
const onCredentialCreated = openNewCredentialMock.mock.calls.at(-1)?.[7]?.onCredentialCreated;
|
||||
expect(onCredentialCreated).toBeTypeOf('function');
|
||||
onCredentialCreated?.({ id: 'new-cred' });
|
||||
|
||||
const last = wrapper.emitted('update:config')?.at(-1)?.[0] as Partial<AgentJsonConfig>;
|
||||
expect(getWebSearchConfig(last)).toEqual({
|
||||
enabled: true,
|
||||
provider,
|
||||
credential: 'new-cred',
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it('offers an enabled "Create new credential" action without the permission tooltip when the user can create credentials in the project', () => {
|
||||
const wrapper = mountWithFallbackPicker();
|
||||
|
||||
const createButton = findCreateCredentialButton(wrapper);
|
||||
expect(createButton.text()).toBe('Create new credential');
|
||||
expect(createButton.attributes()).not.toHaveProperty('disabled');
|
||||
expect(getCreateCredentialTooltip(wrapper).disabled).toBe('true');
|
||||
});
|
||||
|
||||
it('disables "Create new credential" with the permission tooltip and does not open the credential modal when the user cannot create credentials in the project', () => {
|
||||
projectsStoreState.currentProject = { id: 'project-1', scopes: ['credential:read'] };
|
||||
|
||||
const wrapper = mountWithFallbackPicker();
|
||||
|
||||
expect(
|
||||
wrapper.findComponent(AgentCredentialSelect).props('credentialPermissions'),
|
||||
).toMatchObject({ create: false });
|
||||
expect(findCreateCredentialButton(wrapper).attributes()).toHaveProperty('disabled');
|
||||
expect(getCreateCredentialTooltip(wrapper)).toEqual({
|
||||
disabled: 'false',
|
||||
content: 'Your current role does not allow you to create credentials',
|
||||
});
|
||||
|
||||
// Even if the dropdown emits `create` anyway, the panel must not open the modal.
|
||||
wrapper.findComponent(AgentCredentialSelect).vm.$emit('create');
|
||||
expect(openNewCredentialMock).not.toHaveBeenCalled();
|
||||
expect(wrapper.emitted('update:config')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not open the credential modal from the fallback picker while the panel is disabled', () => {
|
||||
const wrapper = mountWithFallbackPicker({ disabled: true });
|
||||
|
||||
wrapper.findComponent(AgentCredentialSelect).vm.$emit('create');
|
||||
|
||||
expect(openNewCredentialMock).not.toHaveBeenCalled();
|
||||
expect(wrapper.emitted('update:config')).toBeUndefined();
|
||||
});
|
||||
|
||||
describe('resolving the project whose scopes decide whether a credential can be created', () => {
|
||||
const canCreate = CREDENTIAL_CREATE_SCOPES;
|
||||
const cannotCreate = ['credential:read'];
|
||||
|
||||
it.each<{
|
||||
name: string;
|
||||
projectId: string;
|
||||
currentProject: MockProject;
|
||||
personalProject: MockProject;
|
||||
myProjects: MockProject[];
|
||||
expectedCreate: boolean;
|
||||
}>([
|
||||
{
|
||||
name: 'uses the current project when it matches the panel project',
|
||||
projectId: 'project-1',
|
||||
currentProject: { id: 'project-1', scopes: canCreate },
|
||||
personalProject: { id: 'project-2', scopes: cannotCreate },
|
||||
myProjects: [{ id: 'project-3', scopes: cannotCreate }],
|
||||
expectedCreate: true,
|
||||
},
|
||||
{
|
||||
name: 'falls back to the personal project when the current project does not match',
|
||||
projectId: 'project-2',
|
||||
currentProject: { id: 'project-1', scopes: cannotCreate },
|
||||
personalProject: { id: 'project-2', scopes: canCreate },
|
||||
myProjects: [{ id: 'project-3', scopes: cannotCreate }],
|
||||
expectedCreate: true,
|
||||
},
|
||||
{
|
||||
name: 'falls back to the matching entry in myProjects when neither the current nor the personal project match',
|
||||
projectId: 'project-3',
|
||||
currentProject: { id: 'project-1', scopes: cannotCreate },
|
||||
personalProject: { id: 'project-2', scopes: cannotCreate },
|
||||
myProjects: [{ id: 'project-3', scopes: canCreate }],
|
||||
expectedCreate: true,
|
||||
},
|
||||
{
|
||||
name: 'denies creating credentials when no known project matches the panel project',
|
||||
projectId: 'project-unknown',
|
||||
currentProject: { id: 'project-1', scopes: canCreate },
|
||||
personalProject: { id: 'project-2', scopes: canCreate },
|
||||
myProjects: [{ id: 'project-3', scopes: canCreate }],
|
||||
expectedCreate: false,
|
||||
},
|
||||
])('$name', ({ projectId, currentProject, personalProject, myProjects, expectedCreate }) => {
|
||||
projectsStoreState.currentProject = currentProject;
|
||||
projectsStoreState.personalProject = personalProject;
|
||||
projectsStoreState.myProjects = myProjects;
|
||||
|
||||
const wrapper = mountWithFallbackPicker({ projectId });
|
||||
|
||||
expect(
|
||||
wrapper.findComponent(AgentCredentialSelect).props('credentialPermissions'),
|
||||
).toMatchObject({ create: expectedCreate });
|
||||
|
||||
wrapper.findComponent(AgentCredentialSelect).vm.$emit('create');
|
||||
if (expectedCreate) {
|
||||
expect(openNewCredentialMock).toHaveBeenCalledWith(
|
||||
'braveSearchApi',
|
||||
false,
|
||||
false,
|
||||
projectId,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
expect.objectContaining({ hideAskAssistant: true }),
|
||||
);
|
||||
} else {
|
||||
expect(openNewCredentialMock).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('switches fallback web search to native and emits native provider tools', async () => {
|
||||
const config = makeConfig({
|
||||
config: { webSearch: { enabled: true, provider: 'brave', credential: 'brave-1' } },
|
||||
} as Partial<AgentJsonConfig>);
|
||||
const wrapper = mount(AgentWebSearchSection, {
|
||||
props: { config },
|
||||
global: { stubs: globalStubs },
|
||||
});
|
||||
|
||||
emitSelectValue(wrapper, 'agent-web-search-method', 'native');
|
||||
await nextTick();
|
||||
|
||||
const events = wrapper.emitted('update:config') ?? [];
|
||||
const last = events[events.length - 1][0] as Partial<AgentJsonConfig>;
|
||||
expect(getWebSearchConfig(last)).toEqual({ enabled: true, provider: 'native' });
|
||||
expect(last.providerTools).toEqual({ 'anthropic.web_search': { maxUses: 5 } });
|
||||
});
|
||||
|
||||
it('preserves fallback web search credential when switching away and back to the same fallback provider', async () => {
|
||||
const config = makeConfig({
|
||||
config: { webSearch: { enabled: true, provider: 'brave', credential: 'brave-1' } },
|
||||
} as Partial<AgentJsonConfig>);
|
||||
const wrapper = mount(AgentWebSearchSection, {
|
||||
props: { config },
|
||||
global: { stubs: globalStubs },
|
||||
});
|
||||
|
||||
emitSelectValue(wrapper, 'agent-web-search-method', 'native');
|
||||
await nextTick();
|
||||
emitSelectValue(wrapper, 'agent-web-search-method', 'brave');
|
||||
await nextTick();
|
||||
|
||||
const events = wrapper.emitted('update:config') ?? [];
|
||||
const last = events[events.length - 1][0] as Partial<AgentJsonConfig>;
|
||||
expect(getWebSearchConfig(last)).toEqual({
|
||||
enabled: true,
|
||||
provider: 'brave',
|
||||
credential: 'brave-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('clears fallback web search credential when switching fallback providers', async () => {
|
||||
const config = makeConfig({
|
||||
config: { webSearch: { enabled: true, provider: 'brave', credential: 'brave-1' } },
|
||||
} as Partial<AgentJsonConfig>);
|
||||
const wrapper = mount(AgentWebSearchSection, {
|
||||
props: { config },
|
||||
global: { stubs: globalStubs },
|
||||
});
|
||||
|
||||
emitSelectValue(wrapper, 'agent-web-search-method', 'searxng');
|
||||
await nextTick();
|
||||
|
||||
const events = wrapper.emitted('update:config') ?? [];
|
||||
const last = events[events.length - 1][0] as Partial<AgentJsonConfig>;
|
||||
expect(getWebSearchConfig(last)).toEqual({ enabled: true, provider: 'searxng' });
|
||||
});
|
||||
|
||||
it('switches native web search to fallback and strips native provider tools', async () => {
|
||||
const config = makeConfig({
|
||||
config: { webSearch: { enabled: true, provider: 'native' } },
|
||||
providerTools: {
|
||||
'anthropic.web_search': { maxUses: 5 },
|
||||
'openai.image_generation': {},
|
||||
},
|
||||
} as Partial<AgentJsonConfig>);
|
||||
const wrapper = mount(AgentWebSearchSection, {
|
||||
props: { config },
|
||||
global: { stubs: globalStubs },
|
||||
});
|
||||
|
||||
emitSelectValue(wrapper, 'agent-web-search-method', 'brave');
|
||||
await nextTick();
|
||||
|
||||
const events = wrapper.emitted('update:config') ?? [];
|
||||
const last = events[events.length - 1][0] as Partial<AgentJsonConfig>;
|
||||
expect(getWebSearchConfig(last)).toEqual({ enabled: true, provider: 'brave' });
|
||||
expect(last.providerTools).toEqual({ 'openai.image_generation': {} });
|
||||
});
|
||||
});
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
/**
|
||||
* Behavior panel — execution-behavior knobs that used to live in the old
|
||||
* AgentOverviewPanel: native web search, reasoning depth, and tool-call
|
||||
* AgentOverviewPanel: reasoning depth and tool-call
|
||||
* concurrency.
|
||||
*/
|
||||
import { ref, computed, watch } from 'vue';
|
||||
|
|
@ -9,14 +9,8 @@ import { useDebounceFn } from '@vueuse/core';
|
|||
import { AGENT_REASONING_LEVELS, type AgentReasoningLevel } from '@n8n/api-types';
|
||||
import { N8nInputNumber, N8nOption, N8nSelect, N8nSwitch2, N8nText } from '@n8n/design-system';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { getResourcePermissions } from '@n8n/permissions';
|
||||
|
||||
import AgentCredentialSelect, { type AgentCredentialOption } from './AgentCredentialSelect.vue';
|
||||
import AgentPanel from './AgentPanel.vue';
|
||||
|
||||
import { useUIStore } from '@/app/stores/ui.store';
|
||||
import { useProjectsStore } from '@/features/collaboration/projects/projects.store';
|
||||
import { useCredentialsStore } from '@/features/credentials/credentials.store';
|
||||
import { useModelCatalog } from '../composables/useModelCatalog';
|
||||
import type { AgentJsonConfig } from '../types';
|
||||
import {
|
||||
|
|
@ -25,30 +19,15 @@ import {
|
|||
type AnthropicCacheTtl,
|
||||
} from '../provider-capabilities';
|
||||
import { modelToString, parseModelString, parseProvider } from '../utils/model-string';
|
||||
import {
|
||||
getNativeWebSearchArgs,
|
||||
getWebSearchMethod,
|
||||
type FallbackWebSearchProvider,
|
||||
type NativeWebSearchArgs,
|
||||
type WebSearchMethod,
|
||||
withWebSearchConfig,
|
||||
} from '../utils/nativeWebSearch';
|
||||
import shared from '../styles/agent-panel.module.scss';
|
||||
|
||||
const i18n = useI18n();
|
||||
const credentialsStore = useCredentialsStore();
|
||||
const projectsStore = useProjectsStore();
|
||||
const uiStore = useUIStore();
|
||||
const { catalog, ensureLoaded } = useModelCatalog();
|
||||
const DEFAULT_CAPABILITIES = {
|
||||
promptCaching: false,
|
||||
webSearch: false,
|
||||
providerTools: [],
|
||||
} as const;
|
||||
const ANTHROPIC_WEB_SEARCH_DEFAULT_MAX_USES = 5;
|
||||
const SEARCH_CONTEXT_SIZE_OPTIONS = ['low', 'medium', 'high'] as const;
|
||||
type SearchContextSize = (typeof SEARCH_CONTEXT_SIZE_OPTIONS)[number];
|
||||
type WebSearchSelectValue = 'off' | WebSearchMethod;
|
||||
|
||||
function normalizeReasoningLevel(value: unknown): AgentReasoningLevel {
|
||||
return AGENT_REASONING_LEVELS.find((level) => level === value) ?? 'medium';
|
||||
|
|
@ -83,7 +62,6 @@ const reasoningHintKey = computed(() => {
|
|||
return 'agents.builder.advanced.reasoning.hint';
|
||||
});
|
||||
const capabilities = computed(() => PROVIDER_CAPABILITIES[provider.value] ?? DEFAULT_CAPABILITIES);
|
||||
const hasNativeWebSearch = computed(() => Boolean(capabilities.value.webSearch));
|
||||
|
||||
watch(
|
||||
() => props.projectId,
|
||||
|
|
@ -199,20 +177,6 @@ const {
|
|||
// Reasoning
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const webSearchEnabled = ref(props.config?.config?.webSearch?.enabled === true);
|
||||
const webSearchMethod = ref<WebSearchSelectValue>(
|
||||
webSearchEnabled.value ? getWebSearchMethod(props.config, hasNativeWebSearch.value) : 'off',
|
||||
);
|
||||
const webSearchArgs = ref<NativeWebSearchArgs>(
|
||||
getNativeWebSearchArgs(props.config, capabilities.value.webSearch),
|
||||
);
|
||||
const webSearchMaxUses = ref('');
|
||||
const webSearchExternalAccess = ref(true);
|
||||
const webSearchContextSize = ref<SearchContextSize>('medium');
|
||||
const fallbackWebSearchProvider = ref<FallbackWebSearchProvider>(
|
||||
props.config?.config?.webSearch?.provider === 'searxng' ? 'searxng' : 'brave',
|
||||
);
|
||||
const fallbackWebSearchCredential = ref(props.config?.config?.webSearch?.credential ?? '');
|
||||
const reasoningEnabled = ref(props.config?.config?.reasoning !== undefined);
|
||||
const reasoningLevel = ref<AgentReasoningLevel>(
|
||||
normalizeReasoningLevel(props.config?.config?.reasoning),
|
||||
|
|
@ -224,166 +188,19 @@ function anthropicTtlFrom(cfg: AgentJsonConfig | null): AnthropicCacheTtl {
|
|||
|
||||
const anthropicTtl = ref<AnthropicCacheTtl>(anthropicTtlFrom(props.config));
|
||||
|
||||
function syncWebSearchOptions(args: NativeWebSearchArgs) {
|
||||
webSearchMaxUses.value =
|
||||
typeof args.maxUses === 'number'
|
||||
? String(args.maxUses)
|
||||
: String(ANTHROPIC_WEB_SEARCH_DEFAULT_MAX_USES);
|
||||
webSearchExternalAccess.value =
|
||||
typeof args.externalWebAccess === 'boolean' ? args.externalWebAccess : true;
|
||||
webSearchContextSize.value =
|
||||
args.searchContextSize === 'low' ||
|
||||
args.searchContextSize === 'medium' ||
|
||||
args.searchContextSize === 'high'
|
||||
? args.searchContextSize
|
||||
: 'medium';
|
||||
}
|
||||
|
||||
syncWebSearchOptions(webSearchArgs.value);
|
||||
|
||||
watch(
|
||||
() => props.config,
|
||||
(cfg) => {
|
||||
if (!cfg) return;
|
||||
reasoningEnabled.value = cfg.config?.reasoning !== undefined;
|
||||
reasoningLevel.value = normalizeReasoningLevel(cfg.config?.reasoning);
|
||||
anthropicTtl.value = anthropicTtlFrom(cfg);
|
||||
syncConcurrency(cfg);
|
||||
syncMaxIterations(cfg);
|
||||
webSearchEnabled.value = cfg.config?.webSearch?.enabled === true;
|
||||
webSearchMethod.value = webSearchEnabled.value
|
||||
? getWebSearchMethod(cfg, hasNativeWebSearch.value)
|
||||
: 'off';
|
||||
webSearchArgs.value = getNativeWebSearchArgs(cfg, capabilities.value.webSearch);
|
||||
fallbackWebSearchProvider.value = webSearchMethod.value === 'searxng' ? 'searxng' : 'brave';
|
||||
fallbackWebSearchCredential.value = cfg.config?.webSearch?.credential ?? '';
|
||||
syncWebSearchOptions(webSearchArgs.value);
|
||||
(config) => {
|
||||
if (!config) return;
|
||||
reasoningEnabled.value = config.config?.reasoning !== undefined;
|
||||
reasoningLevel.value = normalizeReasoningLevel(config.config?.reasoning);
|
||||
anthropicTtl.value = anthropicTtlFrom(config);
|
||||
syncConcurrency(config);
|
||||
syncMaxIterations(config);
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
const fallbackCredentialType = computed(() =>
|
||||
webSearchMethod.value === 'searxng' ? 'searXngApi' : 'braveSearchApi',
|
||||
);
|
||||
const fallbackCredentials = computed<AgentCredentialOption[]>(() =>
|
||||
credentialsStore.allCredentials
|
||||
.filter((credential) => credential.type === fallbackCredentialType.value)
|
||||
.map((credential) => ({
|
||||
id: credential.id,
|
||||
name: credential.name,
|
||||
typeDisplayName: credentialsStore.getCredentialTypeByName(credential.type)?.displayName,
|
||||
homeProject: credential.homeProject,
|
||||
})),
|
||||
);
|
||||
|
||||
const projectForPermissions = computed(() => {
|
||||
if (projectsStore.currentProject?.id === props.projectId) return projectsStore.currentProject;
|
||||
if (projectsStore.personalProject?.id === props.projectId) return projectsStore.personalProject;
|
||||
return projectsStore.myProjects.find((project) => project.id === props.projectId) ?? null;
|
||||
});
|
||||
|
||||
const credentialPermissions = computed(() => {
|
||||
const permissions = getResourcePermissions(projectForPermissions.value?.scopes).credential;
|
||||
return { ...permissions, create: !!permissions.create };
|
||||
});
|
||||
|
||||
function buildWebSearchArgs(): NativeWebSearchArgs {
|
||||
const tool = capabilities.value.webSearch;
|
||||
if (!tool || webSearchMethod.value !== 'native') return {};
|
||||
|
||||
if (tool === 'anthropic.web_search') {
|
||||
const maxUses = Number(webSearchMaxUses.value);
|
||||
return {
|
||||
...(Number.isFinite(maxUses) && maxUses > 0 && { maxUses }),
|
||||
};
|
||||
}
|
||||
|
||||
if (tool === 'openai.web_search') {
|
||||
return {
|
||||
externalWebAccess: webSearchExternalAccess.value,
|
||||
searchContextSize: webSearchContextSize.value,
|
||||
};
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
function emitWebSearchConfig() {
|
||||
if (!webSearchEnabled.value) return;
|
||||
const method = webSearchMethod.value === 'off' ? 'native' : webSearchMethod.value;
|
||||
emit(
|
||||
'update:config',
|
||||
withWebSearchConfig(
|
||||
props.config,
|
||||
true,
|
||||
method,
|
||||
capabilities.value.webSearch,
|
||||
buildWebSearchArgs(),
|
||||
fallbackWebSearchCredential.value,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function onWebSearchOptionInput() {
|
||||
emitWebSearchConfig();
|
||||
}
|
||||
|
||||
function onWebSearchMethodChange(value: WebSearchSelectValue) {
|
||||
webSearchMethod.value = value;
|
||||
webSearchEnabled.value = value !== 'off';
|
||||
const method = value === 'off' ? 'native' : value;
|
||||
const nextFallbackProvider = value === 'brave' || value === 'searxng' ? value : null;
|
||||
if (nextFallbackProvider && nextFallbackProvider !== fallbackWebSearchProvider.value) {
|
||||
fallbackWebSearchCredential.value = '';
|
||||
}
|
||||
if (nextFallbackProvider) {
|
||||
fallbackWebSearchProvider.value = nextFallbackProvider;
|
||||
}
|
||||
emit(
|
||||
'update:config',
|
||||
withWebSearchConfig(
|
||||
props.config,
|
||||
webSearchEnabled.value,
|
||||
method,
|
||||
capabilities.value.webSearch,
|
||||
buildWebSearchArgs(),
|
||||
fallbackWebSearchCredential.value,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function onFallbackCredentialChange(value: string) {
|
||||
fallbackWebSearchCredential.value = value;
|
||||
emit(
|
||||
'update:config',
|
||||
withWebSearchConfig(
|
||||
props.config,
|
||||
webSearchEnabled.value,
|
||||
webSearchMethod.value === 'off' ? 'native' : webSearchMethod.value,
|
||||
capabilities.value.webSearch,
|
||||
buildWebSearchArgs(),
|
||||
value,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function onCreateFallbackCredential() {
|
||||
if (props.disabled || !credentialPermissions.value.create) return;
|
||||
uiStore.openNewCredential(
|
||||
fallbackCredentialType.value,
|
||||
false,
|
||||
false,
|
||||
props.projectId,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
hideAskAssistant: true,
|
||||
onCredentialCreated: (credential) => onFallbackCredentialChange(credential.id),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function emitReasoning() {
|
||||
emit('update:config', {
|
||||
config: { ...props.config?.config, reasoning: reasoningLevel.value },
|
||||
|
|
@ -419,163 +236,6 @@ function onAnthropicTtlChange(value: AnthropicCacheTtl) {
|
|||
|
||||
<template>
|
||||
<div :class="$style.panels" data-testid="agent-behavior-panel">
|
||||
<AgentPanel
|
||||
:header="i18n.baseText('agents.builder.advanced.webSearch.label')"
|
||||
:description="i18n.baseText('agents.builder.advanced.webSearch.hint')"
|
||||
>
|
||||
<div :class="$style.content" data-testid="agent-advanced-content">
|
||||
<div :class="$style.settingGroup">
|
||||
<div :class="$style.row">
|
||||
<N8nText step="sm" bold :class="shared.dataEntryLabel">{{
|
||||
i18n.baseText('agents.builder.advanced.webSearch.method.label')
|
||||
}}</N8nText>
|
||||
<N8nSelect
|
||||
:model-value="webSearchMethod"
|
||||
size="small"
|
||||
:disabled="props.disabled"
|
||||
:class="$style.shortInput"
|
||||
data-testid="agent-web-search-method"
|
||||
@update:model-value="(v) => onWebSearchMethodChange(v as WebSearchSelectValue)"
|
||||
>
|
||||
<N8nOption
|
||||
value="off"
|
||||
:label="i18n.baseText('agents.builder.advanced.webSearch.method.off')"
|
||||
/>
|
||||
<N8nOption
|
||||
v-if="capabilities.webSearch"
|
||||
value="native"
|
||||
:label="i18n.baseText('agents.builder.advanced.webSearch.method.native')"
|
||||
/>
|
||||
<N8nOption
|
||||
value="brave"
|
||||
:label="i18n.baseText('agents.builder.advanced.webSearch.fallbackProvider.brave')"
|
||||
/>
|
||||
<N8nOption
|
||||
value="searxng"
|
||||
:label="i18n.baseText('agents.builder.advanced.webSearch.fallbackProvider.searxng')"
|
||||
/>
|
||||
</N8nSelect>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="webSearchEnabled"
|
||||
:class="$style.subSettings"
|
||||
data-testid="agent-web-search-settings"
|
||||
>
|
||||
<div
|
||||
v-if="
|
||||
webSearchMethod === 'native' && capabilities.webSearch === 'anthropic.web_search'
|
||||
"
|
||||
:class="$style.row"
|
||||
>
|
||||
<div :class="$style.rowLabel">
|
||||
<N8nText step="sm" bold :class="shared.dataEntryLabel">{{
|
||||
i18n.baseText('agents.builder.advanced.webSearch.maxUses.label')
|
||||
}}</N8nText>
|
||||
<N8nText size="small" :class="shared.dataEntrySubLabel">
|
||||
{{ i18n.baseText('agents.builder.advanced.webSearch.maxUses.hint') }}
|
||||
</N8nText>
|
||||
</div>
|
||||
<N8nInputNumber
|
||||
:model-value="Number(webSearchMaxUses)"
|
||||
:min="1"
|
||||
:precision="0"
|
||||
:controls="false"
|
||||
:disabled="props.disabled"
|
||||
:class="$style.shortInput"
|
||||
data-testid="agent-web-search-max-uses"
|
||||
@update:model-value="
|
||||
(v) => {
|
||||
webSearchMaxUses = String(v);
|
||||
onWebSearchOptionInput();
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="webSearchMethod === 'native' && capabilities.webSearch === 'openai.web_search'"
|
||||
:class="$style.row"
|
||||
>
|
||||
<div :class="$style.rowLabel">
|
||||
<N8nText step="sm" bold :class="shared.dataEntryLabel">{{
|
||||
i18n.baseText('agents.builder.advanced.webSearch.externalAccess.label')
|
||||
}}</N8nText>
|
||||
<N8nText size="small" :class="shared.dataEntrySubLabel">
|
||||
{{ i18n.baseText('agents.builder.advanced.webSearch.externalAccess.hint') }}
|
||||
</N8nText>
|
||||
</div>
|
||||
<N8nSwitch2
|
||||
:model-value="webSearchExternalAccess"
|
||||
:disabled="props.disabled"
|
||||
:class="$style.switchControl"
|
||||
data-testid="agent-web-search-external-access"
|
||||
@update:model-value="
|
||||
(v) => {
|
||||
webSearchExternalAccess = Boolean(v);
|
||||
onWebSearchOptionInput();
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="webSearchMethod === 'native' && capabilities.webSearch === 'openai.web_search'"
|
||||
:class="$style.row"
|
||||
>
|
||||
<N8nText step="sm" bold :class="shared.dataEntryLabel">{{
|
||||
i18n.baseText('agents.builder.advanced.webSearch.contextSize.label')
|
||||
}}</N8nText>
|
||||
<N8nSelect
|
||||
:model-value="webSearchContextSize"
|
||||
size="small"
|
||||
:disabled="props.disabled"
|
||||
:class="$style.shortInput"
|
||||
data-testid="agent-web-search-context-size"
|
||||
@update:model-value="
|
||||
(v) => {
|
||||
webSearchContextSize = v as SearchContextSize;
|
||||
onWebSearchOptionInput();
|
||||
}
|
||||
"
|
||||
>
|
||||
<N8nOption
|
||||
v-for="opt in SEARCH_CONTEXT_SIZE_OPTIONS"
|
||||
:key="opt"
|
||||
:value="opt"
|
||||
:label="opt"
|
||||
/>
|
||||
</N8nSelect>
|
||||
</div>
|
||||
|
||||
<div v-if="webSearchMethod !== 'native'" :class="$style.row">
|
||||
<div :class="$style.rowLabel">
|
||||
<N8nText step="sm" bold :class="shared.dataEntryLabel">{{
|
||||
i18n.baseText('agents.builder.advanced.webSearch.credential.label')
|
||||
}}</N8nText>
|
||||
<N8nText size="small" :class="shared.dataEntrySubLabel">
|
||||
{{ i18n.baseText('agents.builder.advanced.webSearch.credential.hint') }}
|
||||
</N8nText>
|
||||
</div>
|
||||
<AgentCredentialSelect
|
||||
:model-value="fallbackWebSearchCredential"
|
||||
:credentials="fallbackCredentials"
|
||||
:placeholder="
|
||||
i18n.baseText('agents.builder.advanced.webSearch.credential.placeholder')
|
||||
"
|
||||
:credential-permissions="credentialPermissions"
|
||||
:disabled="props.disabled"
|
||||
:class="$style.credentialSelect"
|
||||
data-test-id="agent-web-search-fallback-credential"
|
||||
@update:model-value="onFallbackCredentialChange"
|
||||
@create="onCreateFallbackCredential"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AgentPanel>
|
||||
|
||||
<AgentPanel
|
||||
:header="i18n.baseText('agents.builder.advanced.title')"
|
||||
:description="i18n.baseText('agents.builder.advanced.description')"
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import type {
|
|||
import { buildToolRows } from './AgentCapabilitiesSection.utils';
|
||||
import AgentChipButton from './AgentChipButton.vue';
|
||||
import AgentChipRow from './AgentChipRow.vue';
|
||||
import AgentWebSearchSection from './AgentWebSearchSection.vue';
|
||||
|
||||
export type AgentCapabilitySection = 'tools' | 'tasks' | 'skills' | 'subAgents';
|
||||
type CapabilityRow = Exclude<AgentCapabilitySection, 'tasks'> | 'workflows';
|
||||
|
|
@ -706,6 +707,13 @@ function openExistingSubAgentModal(subAgent: {
|
|||
</div>
|
||||
</AgentChipRow>
|
||||
</template>
|
||||
<div :class="$style.divider" aria-hidden="true" />
|
||||
<AgentWebSearchSection
|
||||
:config="props.config"
|
||||
:disabled="props.disabled"
|
||||
:project-id="props.projectId"
|
||||
@update:config="emit('update:config', $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -737,4 +745,11 @@ function openExistingSubAgentModal(subAgent: {
|
|||
align-items: center;
|
||||
gap: var(--spacing--4xs);
|
||||
}
|
||||
|
||||
.divider {
|
||||
flex: initial;
|
||||
height: 1px;
|
||||
background-color: var(--border-color--subtle);
|
||||
margin-inline: calc(var(--spacing--sm) * -1);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,483 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import {
|
||||
N8nButton,
|
||||
N8nDropdownMenu,
|
||||
N8nInputNumber,
|
||||
N8nSegmentControl,
|
||||
N8nSwitch2,
|
||||
N8nText,
|
||||
N8nIcon,
|
||||
type DropdownMenuItemProps,
|
||||
} from '@n8n/design-system';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { getResourcePermissions } from '@n8n/permissions';
|
||||
|
||||
import { useUIStore } from '@/app/stores/ui.store';
|
||||
import { useProjectsStore } from '@/features/collaboration/projects/projects.store';
|
||||
import { useCredentialsStore } from '@/features/credentials/credentials.store';
|
||||
import type { AgentJsonConfig } from '../types';
|
||||
import { AGENT_MODEL_PROVIDER_DEFINITIONS, isAgentModelProvider } from '../model-providers';
|
||||
import { PROVIDER_CAPABILITIES } from '../provider-capabilities';
|
||||
import { parseProvider } from '../utils/model-string';
|
||||
import {
|
||||
getNativeWebSearchArgs,
|
||||
getWebSearchMethod,
|
||||
type FallbackWebSearchProvider,
|
||||
type NativeWebSearchArgs,
|
||||
type WebSearchMethod,
|
||||
withWebSearchConfig,
|
||||
} from '../utils/nativeWebSearch';
|
||||
import AgentCredentialSelect, { type AgentCredentialOption } from './AgentCredentialSelect.vue';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
config: AgentJsonConfig | null;
|
||||
disabled?: boolean;
|
||||
projectId?: string;
|
||||
}>(),
|
||||
{ disabled: false },
|
||||
);
|
||||
const emit = defineEmits<{ 'update:config': [changes: Partial<AgentJsonConfig>] }>();
|
||||
|
||||
const i18n = useI18n();
|
||||
const credentialsStore = useCredentialsStore();
|
||||
const projectsStore = useProjectsStore();
|
||||
const uiStore = useUIStore();
|
||||
const DEFAULT_CAPABILITIES = { webSearch: false } as const;
|
||||
const ANTHROPIC_WEB_SEARCH_DEFAULT_MAX_USES = 5;
|
||||
const SEARCH_CONTEXT_SIZE_OPTIONS = ['low', 'medium', 'high'] as const;
|
||||
type SearchContextSize = (typeof SEARCH_CONTEXT_SIZE_OPTIONS)[number];
|
||||
type WebSearchSelectValue = 'off' | WebSearchMethod;
|
||||
|
||||
const searchContextSizeSegments = computed<Array<{ label: string; value: SearchContextSize }>>(
|
||||
function getSearchContextSizeSegments() {
|
||||
return [
|
||||
{
|
||||
label: i18n.baseText('agents.builder.advanced.webSearch.contextSize.low'),
|
||||
value: 'low',
|
||||
},
|
||||
{
|
||||
label: i18n.baseText('agents.builder.advanced.webSearch.contextSize.medium'),
|
||||
value: 'medium',
|
||||
},
|
||||
{
|
||||
label: i18n.baseText('agents.builder.advanced.webSearch.contextSize.high'),
|
||||
value: 'high',
|
||||
},
|
||||
];
|
||||
},
|
||||
);
|
||||
const provider = computed(() => parseProvider(props.config?.model));
|
||||
const capabilities = computed(() => PROVIDER_CAPABILITIES[provider.value] ?? DEFAULT_CAPABILITIES);
|
||||
const hasNativeWebSearch = computed(() => Boolean(capabilities.value.webSearch));
|
||||
const nativeWebSearchLabel = computed(() =>
|
||||
isAgentModelProvider(provider.value)
|
||||
? AGENT_MODEL_PROVIDER_DEFINITIONS[provider.value].displayName
|
||||
: provider.value,
|
||||
);
|
||||
|
||||
const webSearchMenuItems = computed<Array<DropdownMenuItemProps<WebSearchSelectValue>>>(() => [
|
||||
{
|
||||
id: 'off',
|
||||
label: i18n.baseText('agents.builder.advanced.webSearch.method.off'),
|
||||
checked: webSearchMethod.value === 'off',
|
||||
},
|
||||
...(capabilities.value.webSearch
|
||||
? [
|
||||
{
|
||||
id: 'native' as const,
|
||||
label: nativeWebSearchLabel.value,
|
||||
checked: webSearchMethod.value === 'native',
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: 'brave',
|
||||
label: i18n.baseText('agents.builder.advanced.webSearch.fallbackProvider.brave'),
|
||||
checked: webSearchMethod.value === 'brave',
|
||||
divided: true,
|
||||
},
|
||||
{
|
||||
id: 'searxng',
|
||||
label: i18n.baseText('agents.builder.advanced.webSearch.fallbackProvider.searxng'),
|
||||
checked: webSearchMethod.value === 'searxng',
|
||||
},
|
||||
]);
|
||||
const selectedWebSearchLabel = computed(
|
||||
() => webSearchMenuItems.value.find((item) => item.checked)?.label ?? '',
|
||||
);
|
||||
|
||||
const webSearchEnabled = ref(props.config?.config?.webSearch?.enabled === true);
|
||||
const webSearchMethod = ref<WebSearchSelectValue>(
|
||||
webSearchEnabled.value ? getWebSearchMethod(props.config, hasNativeWebSearch.value) : 'off',
|
||||
);
|
||||
const webSearchArgs = ref<NativeWebSearchArgs>(
|
||||
getNativeWebSearchArgs(props.config, capabilities.value.webSearch),
|
||||
);
|
||||
const webSearchMaxUses = ref('');
|
||||
const webSearchExternalAccess = ref(true);
|
||||
const webSearchContextSize = ref<SearchContextSize>('medium');
|
||||
const fallbackWebSearchProvider = ref<FallbackWebSearchProvider>(
|
||||
props.config?.config?.webSearch?.provider === 'searxng' ? 'searxng' : 'brave',
|
||||
);
|
||||
const fallbackWebSearchCredential = ref(props.config?.config?.webSearch?.credential ?? '');
|
||||
|
||||
function syncWebSearchOptions(args: NativeWebSearchArgs) {
|
||||
webSearchMaxUses.value =
|
||||
typeof args.maxUses === 'number'
|
||||
? String(args.maxUses)
|
||||
: String(ANTHROPIC_WEB_SEARCH_DEFAULT_MAX_USES);
|
||||
webSearchExternalAccess.value =
|
||||
typeof args.externalWebAccess === 'boolean' ? args.externalWebAccess : true;
|
||||
webSearchContextSize.value =
|
||||
args.searchContextSize === 'low' ||
|
||||
args.searchContextSize === 'medium' ||
|
||||
args.searchContextSize === 'high'
|
||||
? args.searchContextSize
|
||||
: 'medium';
|
||||
}
|
||||
|
||||
syncWebSearchOptions(webSearchArgs.value);
|
||||
|
||||
watch(
|
||||
() => props.config,
|
||||
(cfg) => {
|
||||
if (!cfg) return;
|
||||
webSearchEnabled.value = cfg.config?.webSearch?.enabled === true;
|
||||
webSearchMethod.value = webSearchEnabled.value
|
||||
? getWebSearchMethod(cfg, hasNativeWebSearch.value)
|
||||
: 'off';
|
||||
webSearchArgs.value = getNativeWebSearchArgs(cfg, capabilities.value.webSearch);
|
||||
fallbackWebSearchProvider.value = webSearchMethod.value === 'searxng' ? 'searxng' : 'brave';
|
||||
fallbackWebSearchCredential.value = cfg.config?.webSearch?.credential ?? '';
|
||||
syncWebSearchOptions(webSearchArgs.value);
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
const fallbackCredentialType = computed(() =>
|
||||
webSearchMethod.value === 'searxng' ? 'searXngApi' : 'braveSearchApi',
|
||||
);
|
||||
const fallbackCredentials = computed<AgentCredentialOption[]>(() =>
|
||||
credentialsStore.allCredentials
|
||||
.filter((credential) => credential.type === fallbackCredentialType.value)
|
||||
.map((credential) => ({
|
||||
id: credential.id,
|
||||
name: credential.name,
|
||||
typeDisplayName: credentialsStore.getCredentialTypeByName(credential.type)?.displayName,
|
||||
homeProject: credential.homeProject,
|
||||
})),
|
||||
);
|
||||
|
||||
const projectForPermissions = computed(() => {
|
||||
if (projectsStore.currentProject?.id === props.projectId) return projectsStore.currentProject;
|
||||
if (projectsStore.personalProject?.id === props.projectId) return projectsStore.personalProject;
|
||||
return projectsStore.myProjects.find((project) => project.id === props.projectId) ?? null;
|
||||
});
|
||||
|
||||
const credentialPermissions = computed(() => {
|
||||
const permissions = getResourcePermissions(projectForPermissions.value?.scopes).credential;
|
||||
return { ...permissions, create: !!permissions.create };
|
||||
});
|
||||
|
||||
function buildWebSearchArgs(): NativeWebSearchArgs {
|
||||
const tool = capabilities.value.webSearch;
|
||||
if (!tool || webSearchMethod.value !== 'native') return {};
|
||||
|
||||
if (tool === 'anthropic.web_search') {
|
||||
const maxUses = Number(webSearchMaxUses.value);
|
||||
return {
|
||||
...(Number.isFinite(maxUses) && maxUses > 0 && { maxUses }),
|
||||
};
|
||||
}
|
||||
|
||||
if (tool === 'openai.web_search') {
|
||||
return {
|
||||
externalWebAccess: webSearchExternalAccess.value,
|
||||
searchContextSize: webSearchContextSize.value,
|
||||
};
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
function emitWebSearchConfig() {
|
||||
if (!webSearchEnabled.value) return;
|
||||
const method = webSearchMethod.value === 'off' ? 'native' : webSearchMethod.value;
|
||||
emit(
|
||||
'update:config',
|
||||
withWebSearchConfig(
|
||||
props.config,
|
||||
true,
|
||||
method,
|
||||
capabilities.value.webSearch,
|
||||
buildWebSearchArgs(),
|
||||
fallbackWebSearchCredential.value,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function onWebSearchOptionInput() {
|
||||
emitWebSearchConfig();
|
||||
}
|
||||
|
||||
function onWebSearchContextSizeChange(value: SearchContextSize) {
|
||||
webSearchContextSize.value = value;
|
||||
onWebSearchOptionInput();
|
||||
}
|
||||
|
||||
function onWebSearchMethodChange(value: WebSearchSelectValue) {
|
||||
webSearchMethod.value = value;
|
||||
webSearchEnabled.value = value !== 'off';
|
||||
const method = value === 'off' ? 'native' : value;
|
||||
const nextFallbackProvider = value === 'brave' || value === 'searxng' ? value : null;
|
||||
if (nextFallbackProvider && nextFallbackProvider !== fallbackWebSearchProvider.value) {
|
||||
fallbackWebSearchCredential.value = '';
|
||||
}
|
||||
if (nextFallbackProvider) {
|
||||
fallbackWebSearchProvider.value = nextFallbackProvider;
|
||||
}
|
||||
emit(
|
||||
'update:config',
|
||||
withWebSearchConfig(
|
||||
props.config,
|
||||
webSearchEnabled.value,
|
||||
method,
|
||||
capabilities.value.webSearch,
|
||||
buildWebSearchArgs(),
|
||||
fallbackWebSearchCredential.value,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function onFallbackCredentialChange(value: string) {
|
||||
fallbackWebSearchCredential.value = value;
|
||||
emit(
|
||||
'update:config',
|
||||
withWebSearchConfig(
|
||||
props.config,
|
||||
webSearchEnabled.value,
|
||||
webSearchMethod.value === 'off' ? 'native' : webSearchMethod.value,
|
||||
capabilities.value.webSearch,
|
||||
buildWebSearchArgs(),
|
||||
value,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function onCreateFallbackCredential() {
|
||||
if (props.disabled || !credentialPermissions.value.create) return;
|
||||
uiStore.openNewCredential(
|
||||
fallbackCredentialType.value,
|
||||
false,
|
||||
false,
|
||||
props.projectId,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
hideAskAssistant: true,
|
||||
onCredentialCreated: (credential) => onFallbackCredentialChange(credential.id),
|
||||
},
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="$style.content" data-testid="agent-web-search-content">
|
||||
<div :class="$style.settingGroup">
|
||||
<div :class="$style.row">
|
||||
<div :class="$style.rowLabel">
|
||||
<N8nText step="sm" bold>{{
|
||||
i18n.baseText('agents.builder.advanced.webSearch.label')
|
||||
}}</N8nText>
|
||||
<N8nText step="sm" color="text-light">{{
|
||||
i18n.baseText('agents.builder.advanced.webSearch.hint')
|
||||
}}</N8nText>
|
||||
</div>
|
||||
|
||||
<N8nDropdownMenu
|
||||
:items="webSearchMenuItems"
|
||||
:disabled="props.disabled"
|
||||
placement="bottom-end"
|
||||
data-testid="agent-web-search-method"
|
||||
@select="onWebSearchMethodChange"
|
||||
>
|
||||
<template #trigger>
|
||||
<N8nButton variant="outline" :disabled="props.disabled" :class="$style.shortInput">
|
||||
<span :class="$style.dropdownTriggerLabel">{{ selectedWebSearchLabel }}</span>
|
||||
<N8nIcon icon="chevron-down" size="small" color="text-light" />
|
||||
</N8nButton>
|
||||
</template>
|
||||
</N8nDropdownMenu>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="webSearchEnabled"
|
||||
:class="$style.subSettings"
|
||||
data-testid="agent-web-search-settings"
|
||||
>
|
||||
<div
|
||||
v-if="webSearchMethod === 'native' && capabilities.webSearch === 'anthropic.web_search'"
|
||||
:class="$style.row"
|
||||
>
|
||||
<div :class="$style.rowLabel">
|
||||
<N8nText step="sm" bold>{{
|
||||
i18n.baseText('agents.builder.advanced.webSearch.maxUses.label')
|
||||
}}</N8nText>
|
||||
<N8nText step="sm" color="text-light">
|
||||
{{ i18n.baseText('agents.builder.advanced.webSearch.maxUses.hint') }}
|
||||
</N8nText>
|
||||
</div>
|
||||
<N8nInputNumber
|
||||
:model-value="Number(webSearchMaxUses)"
|
||||
:min="1"
|
||||
:precision="0"
|
||||
:controls="false"
|
||||
:disabled="props.disabled"
|
||||
:class="$style.shortInput"
|
||||
data-testid="agent-web-search-max-uses"
|
||||
@update:model-value="
|
||||
(v) => {
|
||||
webSearchMaxUses = String(v);
|
||||
onWebSearchOptionInput();
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="webSearchMethod === 'native' && capabilities.webSearch === 'openai.web_search'"
|
||||
:class="$style.row"
|
||||
>
|
||||
<div :class="$style.rowLabel">
|
||||
<N8nText step="sm" bold>{{
|
||||
i18n.baseText('agents.builder.advanced.webSearch.externalAccess.label')
|
||||
}}</N8nText>
|
||||
<N8nText step="sm" color="text-light">
|
||||
{{ i18n.baseText('agents.builder.advanced.webSearch.externalAccess.hint') }}
|
||||
</N8nText>
|
||||
</div>
|
||||
<N8nSwitch2
|
||||
:model-value="webSearchExternalAccess"
|
||||
:disabled="props.disabled"
|
||||
:class="$style.switchControl"
|
||||
data-testid="agent-web-search-external-access"
|
||||
@update:model-value="
|
||||
(v) => {
|
||||
webSearchExternalAccess = Boolean(v);
|
||||
onWebSearchOptionInput();
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="webSearchMethod === 'native' && capabilities.webSearch === 'openai.web_search'"
|
||||
:class="$style.row"
|
||||
>
|
||||
<div :class="$style.rowLabel">
|
||||
<N8nText step="sm" bold>{{
|
||||
i18n.baseText('agents.builder.advanced.webSearch.contextSize.label')
|
||||
}}</N8nText>
|
||||
<N8nText step="sm" color="text-light">
|
||||
{{ i18n.baseText('agents.builder.advanced.webSearch.contextSize.hint') }}
|
||||
</N8nText>
|
||||
</div>
|
||||
<N8nSegmentControl
|
||||
:model-value="webSearchContextSize"
|
||||
:options="searchContextSizeSegments"
|
||||
size="default"
|
||||
:disabled="props.disabled"
|
||||
data-testid="agent-web-search-context-size"
|
||||
@update:model-value="onWebSearchContextSizeChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="webSearchMethod !== 'native'" :class="$style.row">
|
||||
<div :class="$style.rowLabel">
|
||||
<N8nText step="sm" bold>{{
|
||||
i18n.baseText('agents.builder.advanced.webSearch.credential.label')
|
||||
}}</N8nText>
|
||||
<N8nText step="sm" color="text-light">
|
||||
{{ i18n.baseText('agents.builder.advanced.webSearch.credential.hint') }}
|
||||
</N8nText>
|
||||
</div>
|
||||
<AgentCredentialSelect
|
||||
:model-value="fallbackWebSearchCredential"
|
||||
:credentials="fallbackCredentials"
|
||||
:placeholder="i18n.baseText('agents.builder.advanced.webSearch.credential.placeholder')"
|
||||
:credential-permissions="credentialPermissions"
|
||||
:disabled="props.disabled"
|
||||
:class="$style.credentialSelect"
|
||||
data-test-id="agent-web-search-fallback-credential"
|
||||
@update:model-value="onFallbackCredentialChange"
|
||||
@create="onCreateFallbackCredential"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style module>
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--sm);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.settingGroup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--sm);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing--sm);
|
||||
min-height: var(--spacing--xl);
|
||||
}
|
||||
|
||||
.rowLabel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--5xs);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.subSettings {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--xs);
|
||||
padding: var(--spacing--xs);
|
||||
border: var(--border);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.shortInput {
|
||||
max-width: 140px;
|
||||
width: fit-content;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dropdownTriggerLabel {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.credentialSelect {
|
||||
max-width: 220px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.switchControl:not([data-disabled]) {
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
Loading…
Reference in New Issue
Block a user