mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
feat(editor): Add agent setup checklist (#39919)
This commit is contained in:
parent
b36c2b506b
commit
883ade4b9d
|
|
@ -54,6 +54,8 @@
|
|||
"generic.openResource": "Open {resource}",
|
||||
"generic.add": "Add",
|
||||
"generic.close": "Close",
|
||||
"generic.expand": "Expand",
|
||||
"generic.collapse": "Collapse",
|
||||
"generic.clear": "Clear",
|
||||
"generic.confirm": "Confirm",
|
||||
"generic.create": "Create",
|
||||
|
|
@ -8353,6 +8355,19 @@
|
|||
"agents.builder.sections.triggers": "Triggers",
|
||||
"agents.builder.agent.title": "Model & instructions",
|
||||
"agents.builder.agent.description": "Choose a model and give the agent instructions",
|
||||
"agents.builder.setupTasks.title": "Finish setup",
|
||||
"agents.builder.setupTasks.done": "Done!",
|
||||
"agents.builder.setupTasks.remaining": "{count} remaining",
|
||||
"agents.builder.setupTasks.tasksDone": "{count} tasks done",
|
||||
"agents.builder.setupTasks.maximize": "Maximize",
|
||||
"agents.builder.setupTasks.minimize": "Minimize",
|
||||
"agents.builder.setupTasks.chooseModel": "Choose a model",
|
||||
"agents.builder.setupTasks.addTool": "Add tools",
|
||||
"agents.builder.setupTasks.addInstructions": "Add instructions",
|
||||
"agents.builder.setupTasks.testAgent": "Test agent",
|
||||
"agents.builder.setupTasks.addChannel": "Add channels",
|
||||
"agents.builder.setupTasks.addChannel.description": "Add a channel to let this agent respond to events from a chat platform.",
|
||||
"agents.builder.setupTasks.publishAgent": "Publish agent",
|
||||
"agents.builder.agent.name.label": "Name",
|
||||
"agents.builder.agent.name.placeholder": "My agent",
|
||||
"agents.builder.agent.personalisation.change": "Change agent icon",
|
||||
|
|
|
|||
|
|
@ -139,7 +139,7 @@ const globalStubs = {
|
|||
'configValidationStatus',
|
||||
'beforePublish',
|
||||
],
|
||||
emits: ['published', 'unpublished', 'reverted'],
|
||||
emits: ['publish-ready', 'published', 'unpublished', 'reverted'],
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -315,6 +315,16 @@ describe('AgentBuilderHeader', () => {
|
|||
expect(wrapper.emitted('reverted')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('forwards publish readiness changes', () => {
|
||||
const wrapper = mountHeader();
|
||||
const publish = wrapper.findComponent({ name: 'AgentPublishButton' });
|
||||
|
||||
publish.vm.$emit('publish-ready', false);
|
||||
publish.vm.$emit('publish-ready', true);
|
||||
|
||||
expect(wrapper.emitted('publish-ready')).toEqual([[false], [true]]);
|
||||
});
|
||||
|
||||
it('forwards header-action from the action menu', () => {
|
||||
const wrapper = mountHeader({ headerActions: [{ id: 'delete', label: 'Delete' }] });
|
||||
const action = getDropdown(wrapper, 'agent-header-actions');
|
||||
|
|
|
|||
|
|
@ -606,11 +606,13 @@ const commonStubs = {
|
|||
'configValidationStatus',
|
||||
'saveStatus',
|
||||
'beforePublish',
|
||||
'tasks',
|
||||
],
|
||||
emits: [
|
||||
'header-action',
|
||||
'open-preview',
|
||||
'close-preview',
|
||||
'publish-ready',
|
||||
'published',
|
||||
'unpublished',
|
||||
'reverted',
|
||||
|
|
@ -2807,6 +2809,73 @@ describe('AgentBuilderView — configuration validation', () => {
|
|||
expect(header.attributes('data-config-validation-status')).toBe('invalid');
|
||||
});
|
||||
|
||||
it.each(['tool', 'MCP server', 'both'])(
|
||||
'completes the add-tool setup task when adding %s',
|
||||
async (kind) => {
|
||||
const wrapper = await renderView();
|
||||
const header = wrapper.findComponent({ name: 'AgentBuilderHeader' });
|
||||
const vm = wrapper.vm as unknown as {
|
||||
onConfigFieldUpdate: (updates: Partial<AgentJsonConfig>) => void;
|
||||
};
|
||||
|
||||
expect(header.props('tasks')).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ id: 'add-tool', state: 'todo' })]),
|
||||
);
|
||||
|
||||
vm.onConfigFieldUpdate({
|
||||
tools: kind === 'MCP server' ? [] : [{ type: 'custom', id: 'custom_tool' }],
|
||||
mcpServers:
|
||||
kind === 'tool'
|
||||
? []
|
||||
: [
|
||||
{
|
||||
name: 'Example MCP',
|
||||
url: 'https://mcp.example.com',
|
||||
authentication: 'none',
|
||||
transport: 'streamableHttp',
|
||||
},
|
||||
],
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
expect(header.props('tasks')).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ id: 'add-tool', state: 'complete' })]),
|
||||
);
|
||||
|
||||
vm.onConfigFieldUpdate({ tools: [], mcpServers: [] });
|
||||
await nextTick();
|
||||
|
||||
expect(header.props('tasks')).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ id: 'add-tool', state: 'todo' })]),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('shows the publish setup task for a tested agent only when the publish button is ready', async () => {
|
||||
fetchedSessionThreads.push({ id: 'thread-tested', updatedAt: '2026-01-02T00:00:00Z' });
|
||||
|
||||
const wrapper = await renderView();
|
||||
const header = wrapper.findComponent({ name: 'AgentBuilderHeader' });
|
||||
|
||||
expect(header.props('tasks')).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ id: 'publish-agent', visible: false })]),
|
||||
);
|
||||
|
||||
header.vm.$emit('publish-ready', true);
|
||||
await nextTick();
|
||||
|
||||
expect(header.props('tasks')).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ id: 'publish-agent', visible: true })]),
|
||||
);
|
||||
|
||||
header.vm.$emit('publish-ready', false);
|
||||
await nextTick();
|
||||
|
||||
expect(header.props('tasks')).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ id: 'publish-agent', visible: false })]),
|
||||
);
|
||||
});
|
||||
|
||||
it('flushes a pending config edit when the builder unmounts', async () => {
|
||||
const wrapper = await renderView();
|
||||
updateConfigMock.mockClear();
|
||||
|
|
|
|||
|
|
@ -105,6 +105,7 @@ interface RenderProps {
|
|||
agent?: AgentResource | null;
|
||||
projectId?: string;
|
||||
agentId?: string;
|
||||
isSaving?: boolean;
|
||||
beforeRevertToPublished?: () => Promise<void> | void;
|
||||
configValidationStatus?: 'valid' | 'invalid' | null;
|
||||
configValidationIssues?: AgentConfigValidationIssue[];
|
||||
|
|
@ -462,6 +463,57 @@ describe('AgentPublishButton', () => {
|
|||
).toBe('true');
|
||||
});
|
||||
|
||||
it('reports readiness when tool credentials and validation change', async () => {
|
||||
const wrapper = await renderComponent({
|
||||
configValidationStatus: 'invalid',
|
||||
configValidationIssues: [
|
||||
{
|
||||
code: 'missing_credential',
|
||||
path: 'tools.0.credential',
|
||||
capability: { kind: 'tool', toolType: 'node', id: 'Lookup', index: 0 },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(wrapper.emitted('publish-ready')).toEqual([[false]]);
|
||||
|
||||
await wrapper.setProps({ configValidationStatus: 'valid', configValidationIssues: [] });
|
||||
expect(wrapper.emitted('publish-ready')?.at(-1)).toEqual([true]);
|
||||
|
||||
await wrapper.setProps({ configValidationStatus: null });
|
||||
expect(wrapper.emitted('publish-ready')?.at(-1)).toEqual([false]);
|
||||
|
||||
await wrapper.setProps({
|
||||
configValidationStatus: 'invalid',
|
||||
configValidationIssues: [notPublishedIssue],
|
||||
});
|
||||
expect(wrapper.emitted('publish-ready')?.at(-1)).toEqual([true]);
|
||||
});
|
||||
|
||||
it('reports readiness when saving, permissions, and the published version change', async () => {
|
||||
const wrapper = await renderComponent({ isSaving: true });
|
||||
expect(wrapper.emitted('publish-ready')).toEqual([[false]]);
|
||||
|
||||
await wrapper.setProps({ isSaving: false });
|
||||
expect(wrapper.emitted('publish-ready')?.at(-1)).toEqual([true]);
|
||||
|
||||
agentPermissionsMock.canPublish.value = false;
|
||||
await flushPromises();
|
||||
expect(wrapper.emitted('publish-ready')?.at(-1)).toEqual([false]);
|
||||
|
||||
agentPermissionsMock.canPublish.value = true;
|
||||
await flushPromises();
|
||||
expect(wrapper.emitted('publish-ready')?.at(-1)).toEqual([true]);
|
||||
|
||||
await wrapper.setProps({ agent: createAgent({ activeVersionId: 'v1', activeVersion }) });
|
||||
expect(wrapper.emitted('publish-ready')?.at(-1)).toEqual([false]);
|
||||
|
||||
await wrapper.setProps({
|
||||
agent: createAgent({ versionId: 'v2', activeVersionId: 'v1', activeVersion }),
|
||||
});
|
||||
expect(wrapper.emitted('publish-ready')?.at(-1)).toEqual([true]);
|
||||
});
|
||||
|
||||
it('aborts the publish request when beforePublish resolves false', async () => {
|
||||
const { publishAgent } = await import('../composables/useAgentApi');
|
||||
const beforePublish = vi.fn().mockResolvedValue(false);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,184 @@
|
|||
import { nextTick } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type {
|
||||
SetupTask,
|
||||
SetupTaskId,
|
||||
} from '../components/AgentSetupTasks/agentSetupTasks.registry';
|
||||
|
||||
vi.mock('@n8n/i18n', () => ({
|
||||
useI18n: () => ({
|
||||
baseText: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@n8n/design-system', () => ({
|
||||
N8nIcon: {
|
||||
template: '<i :data-icon="icon" />',
|
||||
props: ['icon'],
|
||||
},
|
||||
N8nPopover: {
|
||||
template:
|
||||
'<div><div @click="$emit(\'update:open\', !open)"><slot name="trigger" /></div><div v-if="open"><slot name="content" /></div></div>',
|
||||
props: ['open'],
|
||||
emits: ['update:open'],
|
||||
},
|
||||
N8nText: {
|
||||
template: '<component :is="tag ?? \'span\'"><slot /></component>',
|
||||
props: ['tag'],
|
||||
},
|
||||
}));
|
||||
|
||||
import AgentSetupTasks from '../components/AgentSetupTasks/AgentSetupTasks.vue';
|
||||
|
||||
const tasks: Array<SetupTask<SetupTaskId>> = [
|
||||
{
|
||||
id: 'choose-model',
|
||||
titleKey: 'agents.builder.setupTasks.chooseModel',
|
||||
required: true,
|
||||
state: 'complete',
|
||||
visible: true,
|
||||
action: {
|
||||
labelKey: 'agents.builder.setupTasks.chooseModel',
|
||||
target: { kind: 'agent' },
|
||||
path: 'model',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'add-tool',
|
||||
titleKey: 'agents.builder.tools.add',
|
||||
required: false,
|
||||
state: 'todo',
|
||||
visible: false,
|
||||
action: {
|
||||
labelKey: 'agents.builder.tools.add',
|
||||
target: { kind: 'tool' },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'add-instructions',
|
||||
titleKey: 'agents.builder.setupTasks.addInstructions',
|
||||
required: true,
|
||||
state: 'todo',
|
||||
visible: true,
|
||||
action: {
|
||||
labelKey: 'agents.builder.setupTasks.addInstructions',
|
||||
target: { kind: 'agent' },
|
||||
path: 'instructions',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
describe('AgentSetupTasks', () => {
|
||||
it('changes selection with the arrow keys without moving focus and activates the selected task', async () => {
|
||||
const localTasks: Array<SetupTask<SetupTaskId>> = [{ ...tasks[0], state: 'todo' }, tasks[2]];
|
||||
const wrapper = mount(AgentSetupTasks, {
|
||||
props: { tasks: localTasks },
|
||||
global: { stubs: { TransitionGroup: false } },
|
||||
attachTo: document.body,
|
||||
});
|
||||
try {
|
||||
await wrapper.get('button').trigger('click');
|
||||
await nextTick();
|
||||
|
||||
const list = wrapper.get('ul');
|
||||
const taskItems = wrapper.findAll('[role="menuitem"]');
|
||||
(list.element as HTMLElement).focus();
|
||||
expect(document.activeElement).toBe(list.element);
|
||||
for (const taskItem of taskItems) {
|
||||
expect(taskItem.attributes('tabindex')).toBeUndefined();
|
||||
expect(taskItem.attributes('data-selected')).toBe('false');
|
||||
}
|
||||
|
||||
await list.trigger('keydown', { key: 'ArrowDown' });
|
||||
expect(taskItems[0].attributes('data-selected')).toBe('true');
|
||||
expect(document.activeElement).toBe(list.element);
|
||||
|
||||
await list.trigger('keydown', { key: 'ArrowDown' });
|
||||
expect(taskItems[0].attributes('data-selected')).toBe('false');
|
||||
expect(taskItems[1].attributes('data-selected')).toBe('true');
|
||||
expect(document.activeElement).toBe(list.element);
|
||||
|
||||
await list.trigger('keydown', { key: 'Enter' });
|
||||
expect(wrapper.emitted('action')?.[0]).toEqual([localTasks[1]]);
|
||||
|
||||
await list.trigger('keydown', { key: 'ArrowUp' });
|
||||
expect(taskItems[0].attributes('data-selected')).toBe('true');
|
||||
expect(taskItems[1].attributes('data-selected')).toBe('false');
|
||||
expect(document.activeElement).toBe(list.element);
|
||||
await list.trigger('keydown', { key: ' ' });
|
||||
expect(wrapper.emitted('action')?.[1]).toEqual([localTasks[0]]);
|
||||
} finally {
|
||||
wrapper.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('exposes the active task through the named menu without selection attributes', async () => {
|
||||
const localTasks: Array<SetupTask<SetupTaskId>> = [{ ...tasks[0], state: 'todo' }, tasks[2]];
|
||||
const wrapper = mount(AgentSetupTasks, { props: { tasks: localTasks } });
|
||||
try {
|
||||
await wrapper.get('button').trigger('click');
|
||||
const menu = wrapper.get('[role="menu"]');
|
||||
const items = wrapper.findAll('[role="menuitem"]');
|
||||
expect(menu.attributes('aria-label')).toBe('agents.builder.setupTasks.title');
|
||||
expect(menu.attributes('tabindex')).toBe('-1');
|
||||
expect(menu.attributes('aria-activedescendant')).toBeUndefined();
|
||||
const ids = items.map((item) => item.attributes('id'));
|
||||
expect(ids.every((id) => Boolean(id))).toBe(true);
|
||||
expect(new Set(ids).size).toBe(items.length);
|
||||
for (const item of items) {
|
||||
expect(item.attributes('aria-selected')).toBeUndefined();
|
||||
expect(item.attributes('aria-current')).toBeUndefined();
|
||||
}
|
||||
|
||||
await menu.trigger('keydown', { key: 'ArrowDown' });
|
||||
expect(menu.attributes('aria-activedescendant')).toBe(ids[0]);
|
||||
await menu.trigger('keydown', { key: 'ArrowDown' });
|
||||
expect(menu.attributes('aria-activedescendant')).toBe(ids[1]);
|
||||
await menu.trigger('keydown', { key: 'ArrowUp' });
|
||||
expect(menu.attributes('aria-activedescendant')).toBe(ids[0]);
|
||||
await items[1].trigger('mouseenter');
|
||||
expect(menu.attributes('aria-activedescendant')).toBe(ids[1]);
|
||||
|
||||
await wrapper.setProps({ tasks: [{ ...localTasks[0], state: 'complete' }] });
|
||||
expect(menu.attributes('aria-activedescendant')).toBeUndefined();
|
||||
} finally {
|
||||
wrapper.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it.each(['Escape', 'Tab', 'a'])('does not prevent the %s key', async (key) => {
|
||||
const wrapper = mount(AgentSetupTasks, { props: { tasks } });
|
||||
await wrapper.get('button').trigger('click');
|
||||
const event = new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true });
|
||||
wrapper.get('[role="menuitem"]').element.dispatchEvent(event);
|
||||
expect(event.defaultPrevented).toBe(false);
|
||||
});
|
||||
|
||||
it('only shows visible tasks and puts completed tasks last', async () => {
|
||||
const wrapper = mount(AgentSetupTasks, { props: { tasks } });
|
||||
await wrapper.get('button').trigger('click');
|
||||
const taskItems = wrapper.findAll('li');
|
||||
|
||||
expect(taskItems).toHaveLength(2);
|
||||
expect(taskItems[0].text()).toContain('agents.builder.setupTasks.addInstructions');
|
||||
expect(taskItems[1].text()).toContain('agents.builder.setupTasks.chooseModel');
|
||||
expect(taskItems[1].attributes('tabindex')).toBeUndefined();
|
||||
expect(taskItems[1].attributes('role')).toBeUndefined();
|
||||
expect(taskItems[1].attributes('data-selected')).toBe('false');
|
||||
expect(wrapper.text()).not.toContain('agents.builder.tools.add');
|
||||
});
|
||||
|
||||
it('opens and closes the setup popover', async () => {
|
||||
const wrapper = mount(AgentSetupTasks, { props: { tasks } });
|
||||
const trigger = wrapper.get('button');
|
||||
expect(trigger.attributes('type')).toBe('button');
|
||||
expect(wrapper.find('[data-testid="agent-setup-tasks"]').exists()).toBe(false);
|
||||
|
||||
await trigger.trigger('click');
|
||||
expect(wrapper.find('[data-testid="agent-setup-tasks"]').exists()).toBe(true);
|
||||
await trigger.trigger('click');
|
||||
expect(wrapper.find('[data-testid="agent-setup-tasks"]').exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -5,6 +5,7 @@ import { useI18n } from '@n8n/i18n';
|
|||
import type { AgentConfigValidationIssue, AgentFileDto } from '@n8n/api-types';
|
||||
|
||||
import type { AgentBuilderMainTab } from '../composables/useAgentBuilderMainTabs';
|
||||
import type { SetupTask } from './AgentSetupTasks/agentSetupTasks.registry';
|
||||
import type {
|
||||
AgentJsonConfig,
|
||||
AgentJsonVectorStoreConfig,
|
||||
|
|
@ -99,9 +100,42 @@ const emit = defineEmits<{
|
|||
'agent-changed': [];
|
||||
'generate-eval-cases': [];
|
||||
'open-preview': [];
|
||||
'publish-agent': [];
|
||||
'setup-task-action': [task: SetupTask];
|
||||
}>();
|
||||
|
||||
const i18n = useI18n();
|
||||
|
||||
const agentInfoPanel = ref<InstanceType<typeof AgentInfoPanel>>();
|
||||
const agentTriggersSection = ref<InstanceType<typeof AgentTriggersSection>>();
|
||||
|
||||
function onSetupTaskAction(task: SetupTask) {
|
||||
if (task.id === 'publish-agent') {
|
||||
emit('publish-agent');
|
||||
return;
|
||||
}
|
||||
|
||||
if (task.action.target.kind === 'tool') {
|
||||
emit('add-tool', 'tools');
|
||||
return;
|
||||
}
|
||||
|
||||
if (task.action.target.kind === 'channel') {
|
||||
agentTriggersSection.value?.openChannelModal();
|
||||
return;
|
||||
}
|
||||
|
||||
if (task.action.path === 'instructions') {
|
||||
agentInfoPanel.value?.focusInstructions();
|
||||
return;
|
||||
}
|
||||
|
||||
if (task.action.path === 'model') {
|
||||
agentInfoPanel.value?.focusModel();
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ onSetupTaskAction });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -133,6 +167,7 @@ const i18n = useI18n();
|
|||
<div :class="$style.panelAreaContainer">
|
||||
<AgentBuilderTabPanel v-if="activeMainTab === 'agent'" data-testid="agent-tab-content">
|
||||
<AgentInfoPanel
|
||||
ref="agentInfoPanel"
|
||||
:config="localConfig"
|
||||
:disabled="childrenDisabled"
|
||||
:project-id="projectId"
|
||||
|
|
@ -172,6 +207,7 @@ const i18n = useI18n();
|
|||
/>
|
||||
</template>
|
||||
<AgentTriggersSection
|
||||
ref="agentTriggersSection"
|
||||
:key="`${projectId}:${agentId}`"
|
||||
:connected-triggers="connectedTriggers"
|
||||
:disabled="childrenDisabled"
|
||||
|
|
@ -487,4 +523,12 @@ const i18n = useI18n();
|
|||
padding-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.setupTasks {
|
||||
padding-inline: var(--agent-builder-content-padding-inline);
|
||||
padding-block-end: var(--spacing--lg);
|
||||
max-width: var(--agent-builder-content-max-width);
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
* Navigation intents are emitted as events, except for the project breadcrumb
|
||||
* which links back to the owning project/personal page.
|
||||
*/
|
||||
import { computed, onMounted, useCssModule } from 'vue';
|
||||
import { computed, onMounted, ref, useCssModule } from 'vue';
|
||||
import { useRouter, type RouteLocationRaw } from 'vue-router';
|
||||
import type { AgentConfigValidationIssue } from '@n8n/api-types';
|
||||
import {
|
||||
|
|
@ -25,6 +25,8 @@ import ProjectIcon from '@/features/collaboration/projects/components/ProjectIco
|
|||
|
||||
import AgentPublishButton from './AgentPublishButton.vue';
|
||||
import AgentPreviewButton from './AgentPreviewButton.vue';
|
||||
import AgentSetupTasks from './AgentSetupTasks/AgentSetupTasks.vue';
|
||||
import type { SetupTask, SetupTaskId } from './AgentSetupTasks/agentSetupTasks.registry';
|
||||
import { useCreateAgent } from '../composables/useCreateAgent';
|
||||
import { useProjectAgentsList } from '../composables/useProjectAgentsList';
|
||||
import type { AgentResource } from '../types';
|
||||
|
|
@ -45,22 +47,26 @@ const props = defineProps<{
|
|||
configValidationStatus?: 'valid' | 'invalid' | null;
|
||||
configValidationIssues?: AgentConfigValidationIssue[];
|
||||
beforePublish?: () => Promise<boolean>;
|
||||
tasks?: Array<SetupTask<SetupTaskId>>;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'header-action': [item: string];
|
||||
'open-preview': [];
|
||||
'close-preview': [];
|
||||
'publish-ready': [ready: boolean];
|
||||
published: [agent: AgentResource];
|
||||
unpublished: [agent: AgentResource];
|
||||
reverted: [agent: AgentResource];
|
||||
'switch-agent': [agentId: string];
|
||||
'toggle-version-history': [];
|
||||
'setup-task-action': [task: SetupTask<SetupTaskId>];
|
||||
}>();
|
||||
|
||||
const i18n = useI18n();
|
||||
const router = useRouter();
|
||||
const $style = useCssModule();
|
||||
const publishButton = ref<InstanceType<typeof AgentPublishButton>>();
|
||||
|
||||
const { createAgent } = useCreateAgent();
|
||||
const { list: agentsList, ensureLoaded } = useProjectAgentsList(computed(() => props.projectId));
|
||||
|
|
@ -85,6 +91,12 @@ const breadcrumbItems = computed<PathItem[]>(() => [
|
|||
|
||||
const agentDisplayName = computed(() => props.agent?.name ?? '…');
|
||||
|
||||
function publishAgent() {
|
||||
return publishButton.value?.publish();
|
||||
}
|
||||
|
||||
defineExpose({ publishAgent });
|
||||
|
||||
const switcherOptions = computed<Array<DropdownMenuItemProps<string>>>(() => {
|
||||
const list = agentsList.value ?? [];
|
||||
const others = list.filter((a) => a.id !== props.agentId);
|
||||
|
|
@ -224,6 +236,11 @@ function onMenuSelect(id: string) {
|
|||
: i18n.baseText('agents.builder.header.saved')
|
||||
}}
|
||||
</span>
|
||||
<AgentSetupTasks
|
||||
v-if="tasks && tasks.length > 0"
|
||||
:tasks="tasks"
|
||||
@action="emit('setup-task-action', $event)"
|
||||
/>
|
||||
<AgentPreviewButton
|
||||
:is-runnable="props.agent?.isRunnable === true"
|
||||
:is-preview-open="props.isPreviewOpen"
|
||||
|
|
@ -233,6 +250,7 @@ function onMenuSelect(id: string) {
|
|||
@close-preview="emit('close-preview')"
|
||||
/>
|
||||
<AgentPublishButton
|
||||
ref="publishButton"
|
||||
:agent="agent"
|
||||
:project-id="projectId"
|
||||
:agent-id="agentId"
|
||||
|
|
@ -241,6 +259,7 @@ function onMenuSelect(id: string) {
|
|||
:config-validation-status="configValidationStatus"
|
||||
:config-validation-issues="props.configValidationIssues ?? []"
|
||||
:before-publish="beforePublish"
|
||||
@publish-ready="emit('publish-ready', $event)"
|
||||
@published="(a: AgentResource) => emit('published', a)"
|
||||
@unpublished="(a: AgentResource) => emit('unpublished', a)"
|
||||
@reverted="(a: AgentResource) => emit('reverted', a)"
|
||||
|
|
|
|||
|
|
@ -354,6 +354,8 @@ function onSelectCredential(provider: AgentModelProvider, credentialId: string |
|
|||
}
|
||||
|
||||
const instructions = ref(props.config?.instructions ?? '');
|
||||
const instructionsEditor = ref<InstanceType<typeof N8nMarkdownEditor>>();
|
||||
const modelSelector = ref<InstanceType<typeof AgentModelSelector>>();
|
||||
|
||||
// Keep the local editor stable while external config updates arrive.
|
||||
watch(
|
||||
|
|
@ -367,6 +369,14 @@ const emitInstructionsDebounced = useDebounceFn(() => {
|
|||
emit('update:config', { instructions: instructions.value });
|
||||
}, getDebounceTime(DEBOUNCE_TIME.API.HEAVY_OPERATION));
|
||||
|
||||
function focusInstructions() {
|
||||
instructionsEditor.value?.focus();
|
||||
}
|
||||
|
||||
function focusModel() {
|
||||
modelSelector.value?.open();
|
||||
}
|
||||
|
||||
function onInstructionsInput(value: string) {
|
||||
instructions.value = value;
|
||||
emit('draft:config');
|
||||
|
|
@ -376,6 +386,7 @@ function onInstructionsInput(value: string) {
|
|||
}
|
||||
void emitInstructionsDebounced();
|
||||
}
|
||||
defineExpose({ focusInstructions, focusModel });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -397,6 +408,7 @@ function onInstructionsInput(value: string) {
|
|||
</N8nText>
|
||||
</div>
|
||||
<AgentModelSelector
|
||||
ref="modelSelector"
|
||||
:disabled="props.disabled"
|
||||
:selected-model="selectedAgent"
|
||||
:credentials="effectiveCredentials"
|
||||
|
|
@ -479,6 +491,7 @@ function onInstructionsInput(value: string) {
|
|||
</N8nText>
|
||||
</div>
|
||||
<N8nMarkdownEditor
|
||||
ref="instructionsEditor"
|
||||
:id="instructionsEditorId"
|
||||
:class="$style.instructionsDocument"
|
||||
:model-value="instructions"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import type { AgentConfigValidationIssue } from '@n8n/api-types';
|
||||
import { computed } from 'vue';
|
||||
import { computed, watch } from 'vue';
|
||||
import { N8nActionDropdown, N8nButton, N8nIconButton } from '@n8n/design-system';
|
||||
import type { ActionDropdownItem } from '@n8n/design-system';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
|
|
@ -39,6 +39,7 @@ const props = withDefaults(
|
|||
const { canUpdate, canPublish, canUnpublish } = useAgentPermissions(() => props.projectId);
|
||||
|
||||
const emit = defineEmits<{
|
||||
'publish-ready': [ready: boolean];
|
||||
published: [agent: AgentResource];
|
||||
unpublished: [agent: AgentResource];
|
||||
reverted: [agent: AgentResource];
|
||||
|
|
@ -101,6 +102,8 @@ const isPublishDisabled = computed(
|
|||
isConfigInvalid.value,
|
||||
);
|
||||
|
||||
watch(isPublishDisabled, (disabled) => emit('publish-ready', !disabled), { immediate: true });
|
||||
|
||||
const dropdownActions = computed(() => {
|
||||
const actions: Array<ActionDropdownItem<string>> = [
|
||||
{
|
||||
|
|
@ -148,6 +151,8 @@ useKeybindings({
|
|||
},
|
||||
});
|
||||
|
||||
defineExpose({ publish: onPublishClick });
|
||||
|
||||
async function onDropdownSelect(action: string) {
|
||||
if (action === 'publish') {
|
||||
await onPublishClick();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,465 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, useId, useTemplateRef, watch } from 'vue';
|
||||
import type { ComponentPublicInstance } from 'vue';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import type { AgentJsonConfig } from '@n8n/api-types';
|
||||
import { N8nIcon, N8nPopover, N8nText } from '@n8n/design-system';
|
||||
|
||||
import type { SetupTask, SetupTaskId } from './agentSetupTasks.registry';
|
||||
|
||||
const props = defineProps<{
|
||||
tasks: Array<SetupTask<SetupTaskId>>;
|
||||
personalisation?: AgentJsonConfig['personalisation'] | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
action: [task: SetupTask<SetupTaskId>];
|
||||
}>();
|
||||
|
||||
const i18n = useI18n();
|
||||
const taskIdPrefix = useId();
|
||||
const isTriggerShimmering = ref(false);
|
||||
const activeTaskIndex = ref(-1);
|
||||
const isPopoverOpen = ref(false);
|
||||
|
||||
const areTasksResolved = computed(() => props.tasks.every((task) => task.state !== 'unknown'));
|
||||
|
||||
const listRef = useTemplateRef<ComponentPublicInstance>('list');
|
||||
|
||||
watch([isPopoverOpen, areTasksResolved, activeTaskIndex], async ([isOpen, resolved]) => {
|
||||
if (!isOpen || !resolved) return;
|
||||
|
||||
await nextTick();
|
||||
const listElement = listRef.value?.$el;
|
||||
if (!(listElement instanceof HTMLElement)) return;
|
||||
|
||||
const activeTask = listElement.querySelector<HTMLElement>('[tabindex="0"]');
|
||||
(activeTask ?? listElement).focus();
|
||||
});
|
||||
|
||||
const sortedList = computed(() => {
|
||||
return props.tasks
|
||||
.filter((task) => task.visible)
|
||||
.toSorted((a, b) => Number(a.state === 'complete') - Number(b.state === 'complete'));
|
||||
});
|
||||
|
||||
const completedTaskCount = computed(() => {
|
||||
return sortedList.value.filter((task) => task.state === 'complete').length;
|
||||
});
|
||||
|
||||
const completionPercentage = computed(() =>
|
||||
sortedList.value.length > 0 ? (completedTaskCount.value / sortedList.value.length) * 100 : 0,
|
||||
);
|
||||
|
||||
const completedTasksAreGrouped = computed(() => completedTaskCount.value > 3);
|
||||
|
||||
const incompleteTasks = computed(() =>
|
||||
sortedList.value.filter((task) => task.state !== 'complete'),
|
||||
);
|
||||
|
||||
const triggerLabel = computed(() =>
|
||||
i18n.baseText(
|
||||
incompleteTasks.value.length > 0
|
||||
? 'agents.builder.setupTasks.title'
|
||||
: 'agents.builder.setupTasks.done',
|
||||
),
|
||||
);
|
||||
|
||||
function getActiveTaskItem() {
|
||||
const task = incompleteTasks.value[activeTaskIndex.value];
|
||||
return task ? `${taskIdPrefix}-${task.id}` : undefined;
|
||||
}
|
||||
|
||||
function resetActiveTask() {
|
||||
/** Reset this to -1 so active state doesnt appear until keydown */
|
||||
activeTaskIndex.value = -1;
|
||||
}
|
||||
|
||||
function setActiveTask(task: SetupTask<SetupTaskId>) {
|
||||
const index = incompleteTasks.value.findIndex((incompleteTask) => incompleteTask.id === task.id);
|
||||
if (index >= 0) activeTaskIndex.value = index;
|
||||
}
|
||||
|
||||
function handleTaskKeydown(event: KeyboardEvent): void {
|
||||
if (!isPopoverOpen.value) return;
|
||||
if (!['ArrowUp', 'ArrowDown', 'Enter', ' '].includes(event.key)) return;
|
||||
event.preventDefault();
|
||||
|
||||
switch (event.key) {
|
||||
case 'ArrowUp':
|
||||
if (activeTaskIndex.value > 0) activeTaskIndex.value -= 1;
|
||||
return;
|
||||
case 'ArrowDown':
|
||||
if (activeTaskIndex.value < incompleteTasks.value.length - 1) activeTaskIndex.value += 1;
|
||||
return;
|
||||
case 'Enter':
|
||||
case ' ': {
|
||||
const task = incompleteTasks.value[activeTaskIndex.value];
|
||||
if (task) emit('action', task);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<N8nPopover
|
||||
v-model:open="isPopoverOpen"
|
||||
:suppress-auto-focus="true"
|
||||
side="bottom"
|
||||
align="end"
|
||||
width="280px"
|
||||
:content-class="$style.popoverContent"
|
||||
@before-enter="resetActiveTask"
|
||||
>
|
||||
<template #trigger>
|
||||
<button
|
||||
type="button"
|
||||
:class="[
|
||||
$style.trigger,
|
||||
{ [$style.triggerShimmer]: isTriggerShimmering && !isPopoverOpen },
|
||||
]"
|
||||
@animationend.self="isTriggerShimmering = true"
|
||||
>
|
||||
<svg
|
||||
v-if="incompleteTasks.length > 0"
|
||||
:class="$style.progressWheel"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
stroke-width="2"
|
||||
role="progressbar"
|
||||
:aria-label="i18n.baseText('agents.builder.setupTasks.title')"
|
||||
:aria-valuenow="completionPercentage"
|
||||
:aria-valuemin="0"
|
||||
:aria-valuemax="100"
|
||||
>
|
||||
<circle :class="$style.progressTrack" cx="10" cy="10" r="8" />
|
||||
<circle
|
||||
:class="$style.progressValue"
|
||||
cx="10"
|
||||
cy="10"
|
||||
r="8"
|
||||
pathLength="100"
|
||||
stroke-dasharray="100"
|
||||
:stroke-dashoffset="100 - completionPercentage"
|
||||
transform="rotate(-90 10 10)"
|
||||
/>
|
||||
</svg>
|
||||
<N8nIcon v-else icon="check" :size="20" />
|
||||
<N8nText :class="$style.triggerLabel" step="sm" bold>
|
||||
<Transition
|
||||
:enter-active-class="$style.labelEnter"
|
||||
:leave-active-class="$style.labelLeave"
|
||||
>
|
||||
<span :key="triggerLabel">{{ triggerLabel }}</span>
|
||||
</Transition>
|
||||
</N8nText>
|
||||
</button>
|
||||
</template>
|
||||
<template #content>
|
||||
<div :class="$style.container" data-testid="agent-setup-tasks">
|
||||
<TransitionGroup
|
||||
v-if="areTasksResolved"
|
||||
ref="list"
|
||||
tag="ul"
|
||||
role="menu"
|
||||
:aria-label="i18n.baseText('agents.builder.setupTasks.title')"
|
||||
tabindex="-1"
|
||||
:aria-activedescendant="getActiveTaskItem()"
|
||||
:class="$style.list"
|
||||
:move-class="$style.listMove"
|
||||
@keydown="handleTaskKeydown"
|
||||
>
|
||||
<li
|
||||
v-for="(task, index) in completedTasksAreGrouped ? incompleteTasks : sortedList"
|
||||
:id="`${taskIdPrefix}-${task.id}`"
|
||||
:key="task.id"
|
||||
:role="task.state !== 'complete' ? 'menuitem' : undefined"
|
||||
:data-selected="task.state !== 'complete' && activeTaskIndex === index"
|
||||
:class="[$style.listItem, { [$style.isComplete]: task.state === 'complete' }]"
|
||||
@focus="setActiveTask(task)"
|
||||
@mouseenter="setActiveTask(task)"
|
||||
@click="emit('action', task)"
|
||||
>
|
||||
<div :class="[$style.taskIcon, { [$style.isComplete]: task.state === 'complete' }]">
|
||||
<N8nIcon
|
||||
v-if="task.state === 'complete'"
|
||||
icon="check"
|
||||
size="small"
|
||||
:stroke-width="2.5"
|
||||
/>
|
||||
</div>
|
||||
<N8nText bold :class="$style.taskTitle">{{ i18n.baseText(task.titleKey) }}</N8nText>
|
||||
<N8nIcon icon="arrow-right" color="text-light" :class="$style.taskActionIcon" />
|
||||
</li>
|
||||
<li
|
||||
v-if="completedTasksAreGrouped"
|
||||
:key="'completed-tasks'"
|
||||
role="presentation"
|
||||
:class="[$style.listItem, $style.isComplete]"
|
||||
>
|
||||
<div :class="[$style.taskIcon, $style.isComplete]">
|
||||
<N8nIcon icon="check" size="small" :stroke-width="2.5" />
|
||||
</div>
|
||||
<N8nText bold :class="$style.taskTitle">
|
||||
{{
|
||||
i18n.baseText('agents.builder.setupTasks.tasksDone', {
|
||||
interpolate: { count: completedTaskCount.toString() },
|
||||
})
|
||||
}}
|
||||
</N8nText>
|
||||
</li>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</template>
|
||||
</N8nPopover>
|
||||
</template>
|
||||
|
||||
<style module lang="scss">
|
||||
@use '@n8n/design-system/css/mixins/mixins' as mixins;
|
||||
@use '@n8n/design-system/css/mixins/utils' as utils;
|
||||
@use '@n8n/design-system/css/mixins/_focus' as focus;
|
||||
@use '@n8n/design-system/css/mixins/motion' as motion;
|
||||
|
||||
.popoverContent {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.trigger {
|
||||
--animation--fade-in--translate: 0;
|
||||
--animation--fade-in--blur: 0;
|
||||
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: var(--spacing--2xs);
|
||||
width: fit-content;
|
||||
border: 1px solid transparent;
|
||||
padding-inline: var(--spacing--3xs);
|
||||
height: var(--height--md);
|
||||
border-radius: var(--radius--full);
|
||||
background-color: var(--color--purple-100);
|
||||
color: var(--color--purple-900);
|
||||
|
||||
@include motion.fade-in;
|
||||
animation-delay: calc(var(--duration--slow) / 2);
|
||||
animation-fill-mode: both;
|
||||
|
||||
&:hover,
|
||||
&[data-state='open'] {
|
||||
background-color: var(--color--purple-200);
|
||||
}
|
||||
|
||||
&:focus-visible,
|
||||
&[data-state='open'] {
|
||||
background-color: var(--color--purple-200);
|
||||
@include focus.focus-ring-with-border;
|
||||
}
|
||||
|
||||
> :global(.n8n-text) {
|
||||
padding-inline-end: var(--spacing--2xs);
|
||||
}
|
||||
}
|
||||
|
||||
.triggerLabel {
|
||||
--animation--blur-swap--blur: var(--spacing--5xs);
|
||||
|
||||
display: inline-grid;
|
||||
|
||||
> span {
|
||||
grid-area: 1 / 1;
|
||||
}
|
||||
}
|
||||
|
||||
.labelEnter {
|
||||
@include motion.blur-swap-in;
|
||||
}
|
||||
|
||||
.labelLeave {
|
||||
@include motion.blur-swap-out;
|
||||
}
|
||||
|
||||
.triggerShimmer::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(
|
||||
110deg,
|
||||
transparent 20%,
|
||||
var(--color--pink-300) 38%,
|
||||
var(--color--blue-300) 50%,
|
||||
var(--color--orange-300) 62%,
|
||||
transparent 80%
|
||||
);
|
||||
mix-blend-mode: overlay;
|
||||
background-size: 200% 100%;
|
||||
background-repeat: repeat-x;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
/** Use 1s for the sweep and hide the shimmer for 4s. */
|
||||
animation: triggerShimmer calc(var(--duration--slow) * 5) cubic-bezier(0.15, 0.5, 0.85, 0.5)
|
||||
infinite;
|
||||
|
||||
@include motion.reduced-motion;
|
||||
}
|
||||
|
||||
.triggerShimmer:hover::after {
|
||||
animation-play-state: paused;
|
||||
}
|
||||
|
||||
@keyframes triggerShimmer {
|
||||
0% {
|
||||
background-position: 100% 0;
|
||||
opacity: 0;
|
||||
}
|
||||
2%,
|
||||
18% {
|
||||
opacity: 1;
|
||||
}
|
||||
20%,
|
||||
100% {
|
||||
background-position: -100% 0;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.container {
|
||||
--n8n-agent-setup-task-list--padding: var(--spacing--xs);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 240px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.header {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: var(--n8n-agent-setup-task-list--padding);
|
||||
padding-block-end: 0;
|
||||
user-select: none;
|
||||
}
|
||||
.headerContent {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing--2xs);
|
||||
}
|
||||
.progressWheel {
|
||||
flex-shrink: 0;
|
||||
width: var(--height--2xs);
|
||||
height: var(--height--2xs);
|
||||
}
|
||||
.progressTrack {
|
||||
stroke: var(--border-color);
|
||||
}
|
||||
.progressValue {
|
||||
stroke: var(--color--secondary);
|
||||
animation: progressFill var(--duration--snappy) var(--easing--ease-out) var(--duration--slow)
|
||||
backwards;
|
||||
|
||||
@include motion.reduced-motion;
|
||||
}
|
||||
|
||||
@keyframes progressFill {
|
||||
from {
|
||||
stroke-dashoffset: 100;
|
||||
}
|
||||
}
|
||||
.list {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--4xs);
|
||||
padding-block: calc(var(--n8n-agent-setup-task-list--padding) / 2);
|
||||
padding-inline: var(--n8n-agent-setup-task-list--padding);
|
||||
|
||||
@include mixins.hoverable-scroll-bar;
|
||||
}
|
||||
.listMove {
|
||||
transition: transform var(--duration--base) var(--easing--ease-out-quart);
|
||||
|
||||
@include motion.reduced-motion;
|
||||
|
||||
/** TransitionGroup needs to add custom transition to children. But svg icon needs to stay hidden unless we focus/hover **/
|
||||
> *:not(:global(.n8n-icon)) {
|
||||
animation: taskMoveBlur var(--duration--base) var(--easing--ease-out-quart);
|
||||
|
||||
@include motion.reduced-motion;
|
||||
}
|
||||
}
|
||||
.listItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(var(--n8n-agent-setup-task-list--padding) / 1.5);
|
||||
padding-inline: calc(var(--n8n-agent-setup-task-list--padding) / 2);
|
||||
height: var(--height--md);
|
||||
border-radius: var(--radius);
|
||||
margin-inline: calc(calc(var(--n8n-agent-setup-task-list--padding) / 2) * -1);
|
||||
background-color: transparent;
|
||||
user-select: none;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
|
||||
.taskActionIcon {
|
||||
margin-inline-start: auto;
|
||||
opacity: 0;
|
||||
flex-shrink: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&:not(.isComplete) {
|
||||
&:focus-visible,
|
||||
&[data-selected='true'] {
|
||||
background-color: var(--background--hover);
|
||||
.taskActionIcon {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.isComplete {
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
|
||||
> :global(.n8n-text) {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
}
|
||||
}
|
||||
.taskTitle {
|
||||
@include utils.utils-ellipsis;
|
||||
}
|
||||
.taskIcon {
|
||||
width: var(--height--2xs);
|
||||
height: var(--height--2xs);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: var(--radius--full);
|
||||
border: var(--border);
|
||||
box-shadow: inset 0 0 0 1px var(--border-color);
|
||||
flex-shrink: 0;
|
||||
min-width: 0;
|
||||
|
||||
&.isComplete {
|
||||
background-color: var(--color--neutral-200);
|
||||
border-color: var(--color--neutral-200);
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes taskMoveBlur {
|
||||
0%,
|
||||
100% {
|
||||
filter: blur(0);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
50% {
|
||||
filter: blur(var(--spacing--5xs));
|
||||
opacity: 0.2;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { setupTaskDefinitions, type SetupContext } from './agentSetupTasks.registry';
|
||||
|
||||
function makeContext(): SetupContext {
|
||||
return {
|
||||
config: { loaded: true, model: 'openai:gpt-4.1', instructions: 'Help the user', toolCount: 0 },
|
||||
channels: { loaded: true, ids: [] },
|
||||
publication: { loaded: true, canPublish: true, activeVersionId: null },
|
||||
sessions: { loaded: true, count: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
describe('add-channel setup task', () => {
|
||||
it('does not require a channel to publish an agent', () => {
|
||||
expect(setupTaskDefinitions['add-channel'].required).toBe(false);
|
||||
});
|
||||
|
||||
it('stays hidden when no channel is configured', () => {
|
||||
const context = makeContext();
|
||||
|
||||
expect(setupTaskDefinitions['add-channel'].getVisible()).toBe(false);
|
||||
expect(setupTaskDefinitions['add-channel'].getState(context)).toBe('todo');
|
||||
});
|
||||
|
||||
it('keeps the channel completion state', () => {
|
||||
const context = makeContext();
|
||||
context.channels.ids = ['n8n_chat'];
|
||||
|
||||
expect(setupTaskDefinitions['add-channel'].getState(context)).toBe('complete');
|
||||
});
|
||||
|
||||
it('keeps the state unknown until channels load', () => {
|
||||
const context = makeContext();
|
||||
context.channels.loaded = false;
|
||||
|
||||
expect(setupTaskDefinitions['add-channel'].getState(context)).toBe('unknown');
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,165 @@
|
|||
import type { AgentConfigValidationCapabilityRef } from '@n8n/api-types';
|
||||
import type { BaseTextKey } from '@n8n/i18n';
|
||||
|
||||
export type SetupTaskState = 'unknown' | 'todo' | 'complete';
|
||||
|
||||
export interface SetupContext {
|
||||
config: {
|
||||
loaded: boolean;
|
||||
model: string;
|
||||
instructions: string;
|
||||
toolCount: number;
|
||||
};
|
||||
channels: {
|
||||
loaded: boolean;
|
||||
ids: readonly string[];
|
||||
};
|
||||
publication: {
|
||||
loaded: boolean;
|
||||
canPublish: boolean;
|
||||
activeVersionId: string | null;
|
||||
};
|
||||
sessions: {
|
||||
loaded: boolean;
|
||||
count: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SetupTask<TId extends string = string> {
|
||||
/** Stable across task order and page reloads. */
|
||||
id: TId;
|
||||
/** Name of the task, for example, Add channel. */
|
||||
titleKey: BaseTextKey;
|
||||
/** Description that gives more context about the task. */
|
||||
descriptionKey?: BaseTextKey;
|
||||
/** Whether this task is required to publish the agent. */
|
||||
required: boolean;
|
||||
/** Derived from configuration and validation. */
|
||||
state: SetupTaskState;
|
||||
/** Whether to show the task. */
|
||||
visible?: boolean;
|
||||
/** The action to do when a user selects the task. */
|
||||
action: {
|
||||
labelKey: BaseTextKey;
|
||||
target: AgentConfigValidationCapabilityRef;
|
||||
path?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type SetupTaskDefinition = Omit<SetupTask, 'id' | 'state' | 'visible'> & {
|
||||
getState: (context: SetupContext) => SetupTaskState;
|
||||
getVisible?: (context: SetupContext) => boolean;
|
||||
};
|
||||
|
||||
/** Returns the task state after the agent configuration is available. */
|
||||
function getConfigState(context: SetupContext, complete: boolean): SetupTaskState {
|
||||
if (!context.config.loaded) return 'unknown';
|
||||
|
||||
return complete ? 'complete' : 'todo';
|
||||
}
|
||||
|
||||
function getAddChannelState(context: SetupContext): SetupTaskState {
|
||||
if (!context.channels.loaded) return 'unknown';
|
||||
|
||||
return context.channels.ids.length > 0 ? 'complete' : 'todo';
|
||||
}
|
||||
|
||||
function getPublishAgentState(context: SetupContext): SetupTaskState {
|
||||
if (!context.publication.loaded) return 'unknown';
|
||||
|
||||
return context.publication.activeVersionId ? 'complete' : 'todo';
|
||||
}
|
||||
|
||||
function canTestAgent(context: SetupContext): boolean {
|
||||
return (
|
||||
context.config.model.trim().length > 0 &&
|
||||
context.config.instructions.trim().length > 0 &&
|
||||
context.config.toolCount > 0
|
||||
);
|
||||
}
|
||||
|
||||
function isTestAgentComplete(context: SetupContext): boolean {
|
||||
return context.sessions.loaded && context.sessions.count > 0;
|
||||
}
|
||||
|
||||
function getTestAgentState(context: SetupContext): SetupTaskState {
|
||||
if (!context.sessions.loaded) return 'unknown';
|
||||
|
||||
return isTestAgentComplete(context) ? 'complete' : 'todo';
|
||||
}
|
||||
|
||||
export const setupTaskDefinitions = {
|
||||
'choose-model': {
|
||||
titleKey: 'agents.builder.setupTasks.chooseModel',
|
||||
required: true,
|
||||
action: {
|
||||
labelKey: 'agents.builder.setupTasks.chooseModel',
|
||||
target: { kind: 'agent' },
|
||||
path: 'model',
|
||||
},
|
||||
getState: (context) => getConfigState(context, context.config.model.trim().length > 0),
|
||||
},
|
||||
'add-tool': {
|
||||
titleKey: 'agents.builder.setupTasks.addTool',
|
||||
required: false,
|
||||
action: {
|
||||
labelKey: 'agents.builder.setupTasks.addTool',
|
||||
target: { kind: 'tool' },
|
||||
},
|
||||
getState: (context) => getConfigState(context, context.config.toolCount > 0),
|
||||
},
|
||||
'add-instructions': {
|
||||
titleKey: 'agents.builder.setupTasks.addInstructions',
|
||||
required: true,
|
||||
action: {
|
||||
labelKey: 'agents.builder.setupTasks.addInstructions',
|
||||
target: { kind: 'agent' },
|
||||
path: 'instructions',
|
||||
},
|
||||
getState: (context) => getConfigState(context, context.config.instructions.trim().length > 0),
|
||||
},
|
||||
'test-agent': {
|
||||
titleKey: 'agents.builder.setupTasks.testAgent',
|
||||
required: false,
|
||||
action: {
|
||||
labelKey: 'agents.builder.setupTasks.testAgent',
|
||||
target: { kind: 'agent' },
|
||||
path: 'preview',
|
||||
},
|
||||
getVisible: canTestAgent,
|
||||
getState: getTestAgentState,
|
||||
},
|
||||
'add-channel': {
|
||||
titleKey: 'agents.builder.setupTasks.addChannel',
|
||||
descriptionKey: 'agents.builder.setupTasks.addChannel.description',
|
||||
required: false,
|
||||
/** TODO: Decide if we want to conditionally render this */
|
||||
getVisible: () => false,
|
||||
action: {
|
||||
labelKey: 'agents.builder.setupTasks.addChannel',
|
||||
target: { kind: 'channel' },
|
||||
},
|
||||
getState: getAddChannelState,
|
||||
},
|
||||
'publish-agent': {
|
||||
titleKey: 'agents.builder.setupTasks.publishAgent',
|
||||
getVisible: (context) => context.publication.canPublish && isTestAgentComplete(context),
|
||||
required: false,
|
||||
action: {
|
||||
labelKey: 'agents.builder.setupTasks.publishAgent',
|
||||
target: { kind: 'agent' },
|
||||
},
|
||||
getState: getPublishAgentState,
|
||||
},
|
||||
} satisfies Record<string, SetupTaskDefinition>;
|
||||
|
||||
export type SetupTaskId = keyof typeof setupTaskDefinitions;
|
||||
|
||||
export const setupTaskOrder = [
|
||||
'choose-model',
|
||||
'add-tool',
|
||||
'add-instructions',
|
||||
'test-agent',
|
||||
'add-channel',
|
||||
'publish-agent',
|
||||
] as const satisfies readonly SetupTaskId[];
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
import { computed, ref, watch, type ComputedRef } from 'vue';
|
||||
|
||||
import { TIME } from '@/app/constants/durations';
|
||||
|
||||
import {
|
||||
setupTaskDefinitions,
|
||||
setupTaskOrder,
|
||||
type SetupContext,
|
||||
type SetupTask,
|
||||
type SetupTaskDefinition,
|
||||
type SetupTaskId,
|
||||
type SetupTaskState,
|
||||
} from './agentSetupTasks.registry';
|
||||
|
||||
export function useAgentSetupTasks(context: ComputedRef<SetupContext>) {
|
||||
const tasks = computed<Array<SetupTask<SetupTaskId>>>(() => {
|
||||
return setupTaskOrder.map((id) => {
|
||||
const definition: SetupTaskDefinition = setupTaskDefinitions[id];
|
||||
const { getState, getVisible, ...task } = definition;
|
||||
|
||||
return {
|
||||
...task,
|
||||
id,
|
||||
visible: getVisible?.(context.value) ?? true,
|
||||
state: getState(context.value),
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const isVisible = ref(false);
|
||||
const checklistState = computed<SetupTaskState>(() => {
|
||||
if (tasks.value.some((task) => task.state === 'unknown')) return 'unknown';
|
||||
|
||||
return tasks.value.some((task) => task.visible && task.state !== 'complete')
|
||||
? 'todo'
|
||||
: 'complete';
|
||||
});
|
||||
|
||||
watch(
|
||||
checklistState,
|
||||
(state, _, onCleanup) => {
|
||||
if (state !== 'complete') {
|
||||
isVisible.value = state === 'todo';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isVisible.value) return;
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
isVisible.value = false;
|
||||
}, 5 * TIME.SECOND);
|
||||
|
||||
onCleanup(() => clearTimeout(timeout));
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
return { tasks, isVisible };
|
||||
}
|
||||
|
|
@ -252,6 +252,8 @@ function handleChannelConnected(channelType: string) {
|
|||
void loadChannelDetails();
|
||||
}
|
||||
|
||||
defineExpose({ openChannelModal });
|
||||
|
||||
function handleChannelDisconnected(channelType: string) {
|
||||
emit(
|
||||
'update:connected-triggers',
|
||||
|
|
|
|||
|
|
@ -87,6 +87,7 @@ import {
|
|||
} from '../composables/useAgentConfigAutosave';
|
||||
import { useAgentBuilderMainTabs } from '../composables/useAgentBuilderMainTabs';
|
||||
import { useAgentCapabilitiesActions } from '../composables/useAgentCapabilitiesActions';
|
||||
import { useAgentSetupTasks } from '../components/AgentSetupTasks/useAgentSetupTasks';
|
||||
import {
|
||||
removeProjectAgentFromListCache,
|
||||
upsertProjectAgentsListCache,
|
||||
|
|
@ -123,6 +124,7 @@ import AgentBuilderHeader from '../components/AgentBuilderHeader.vue';
|
|||
import AgentCollaborationBanner from '../components/AgentCollaborationBanner.vue';
|
||||
import AgentBuilderEditorColumn from '../components/AgentBuilderEditorColumn.vue';
|
||||
import AgentBuilderIntro from '../components/AgentBuilderIntro.vue';
|
||||
import type { SetupTask } from '../components/AgentSetupTasks/agentSetupTasks.registry';
|
||||
import AgentPreviewHeader from '../components/AgentPreviewHeader.vue';
|
||||
import AgentPreviewChatPage from '../components/AgentPreviewChatPage.vue';
|
||||
import AgentPreviewDock from '../components/AgentPreviewDock.vue';
|
||||
|
|
@ -670,6 +672,7 @@ async function onSendPreviewToAssistant(event?: AgentSendToAssistantEvent) {
|
|||
* - render the preview chat before the route/config/session state has settled.
|
||||
*/
|
||||
const initialized = ref(false);
|
||||
const previewSessionsLoaded = ref(false);
|
||||
let disposed = false;
|
||||
let latestSessionsFetchRequestId = 0;
|
||||
/**
|
||||
|
|
@ -755,6 +758,63 @@ function markConfigDraftEdited() {
|
|||
configEditRevision += 1;
|
||||
}
|
||||
const connectedTriggers = ref<string[]>([]);
|
||||
const isPublishReady = ref(false);
|
||||
const setupChecklistContext = computed(() => {
|
||||
return {
|
||||
config: {
|
||||
loaded: initialized.value && localConfig.value !== null,
|
||||
model: localConfig.value?.model ?? '',
|
||||
instructions: localConfig.value?.instructions ?? '',
|
||||
toolCount:
|
||||
(localConfig.value?.tools?.length ?? 0) +
|
||||
(localConfig.value?.mcpServers?.length ?? 0) +
|
||||
(localConfig.value?.subAgents?.agents?.length ?? 0),
|
||||
},
|
||||
channels: {
|
||||
loaded: initialized.value,
|
||||
ids: connectedTriggers.value,
|
||||
},
|
||||
publication: {
|
||||
loaded: initialized.value,
|
||||
canPublish:
|
||||
initialized.value &&
|
||||
!isUnsaved.value &&
|
||||
effectiveCanEditAgent.value &&
|
||||
isPublishReady.value,
|
||||
activeVersionId: agent.value?.activeVersionId ?? null,
|
||||
},
|
||||
sessions: {
|
||||
loaded: initialized.value && previewSessionsLoaded.value,
|
||||
count: sessionsStore.previewThreads.length,
|
||||
},
|
||||
};
|
||||
});
|
||||
const { tasks: setupTasks, isVisible: areSetupTasksVisible } =
|
||||
useAgentSetupTasks(setupChecklistContext);
|
||||
const builderHeader = useTemplateRef<{ publishAgent: () => Promise<void> | undefined }>(
|
||||
'builderHeader',
|
||||
);
|
||||
const editorColumn = useTemplateRef<{ onSetupTaskAction: (task: SetupTask) => void }>(
|
||||
'editorColumn',
|
||||
);
|
||||
|
||||
async function onSetupTaskAction(task: SetupTask) {
|
||||
if (task.action.path === 'preview') {
|
||||
await onOpenPreview();
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeMainTab.value !== 'agent') {
|
||||
activeMainTab.value = 'agent';
|
||||
await nextTick();
|
||||
}
|
||||
|
||||
editorColumn.value?.onSetupTaskAction(task);
|
||||
}
|
||||
|
||||
function onSetupTaskPublishAgent() {
|
||||
void builderHeader.value?.publishAgent();
|
||||
}
|
||||
/** Bumped when the config changes outside the local editor (modal flows, version revert) so the Tasks panel reloads. */
|
||||
const tasksReloadKey = ref(0);
|
||||
const versionHistoryPanel = useTemplateRef<{ refresh: () => Promise<void> }>('versionHistoryPanel');
|
||||
|
|
@ -2561,6 +2621,7 @@ async function initialize({ preserveState = false }: { preserveState?: boolean }
|
|||
// Stop any in-flight auto-refresh from the previous agent before kicking
|
||||
// off a new fetch — keeps the store tied to the current project/agent.
|
||||
sessionsStore.stopAutoRefresh();
|
||||
previewSessionsLoaded.value = false;
|
||||
if (!isUnsaved.value) {
|
||||
void sessionsStore
|
||||
.fetchThreads(targetProjectId, targetAgentId)
|
||||
|
|
@ -2570,6 +2631,7 @@ async function initialize({ preserveState = false }: { preserveState?: boolean }
|
|||
})
|
||||
.finally(() => {
|
||||
if (!isCurrentInitialization()) return;
|
||||
previewSessionsLoaded.value = true;
|
||||
sessionsStore.startAutoRefresh();
|
||||
});
|
||||
}
|
||||
|
|
@ -2895,6 +2957,7 @@ useKeybindings({
|
|||
/>
|
||||
<AgentBuilderHeader
|
||||
v-else
|
||||
ref="builderHeader"
|
||||
:agent="agent"
|
||||
:project-id="projectId"
|
||||
:agent-id="agentId"
|
||||
|
|
@ -2909,13 +2972,16 @@ useKeybindings({
|
|||
:config-validation-issues="configValidation?.issues ?? []"
|
||||
:before-publish="refreshValidationBeforePublish"
|
||||
:is-preview-open="isPreviewDockOpen"
|
||||
:tasks="areSetupTasksVisible ? setupTasks : undefined"
|
||||
@header-action="onHeaderAction"
|
||||
@open-preview="onOpenPreview"
|
||||
@close-preview="closePreviewDock"
|
||||
@publish-ready="isPublishReady = $event"
|
||||
@published="onPublished"
|
||||
@unpublished="onUnpublished"
|
||||
@reverted="onReverted"
|
||||
@switch-agent="onSwitchAgent"
|
||||
@setup-task-action="onSetupTaskAction"
|
||||
/>
|
||||
<AgentCollaborationBanner v-if="!isArtifactMode" />
|
||||
<div
|
||||
|
|
@ -3054,6 +3120,7 @@ useKeybindings({
|
|||
/>
|
||||
|
||||
<AgentBuilderEditorColumn
|
||||
ref="editorColumn"
|
||||
v-else
|
||||
v-model:active-main-tab="activeMainTab"
|
||||
:class="$style.editorColumn"
|
||||
|
|
@ -3106,6 +3173,7 @@ useKeybindings({
|
|||
@agent-changed="refreshAgentAfterIntegrationChange"
|
||||
@generate-eval-cases="onGenerateEvalCases"
|
||||
@open-preview="onOpenPreview"
|
||||
@publish-agent="onSetupTaskPublishAgent"
|
||||
/>
|
||||
|
||||
<AgentVersionHistoryPanel
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user