mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
feat(editor): Attach skills hub skills from a picker in the agent builder (no-changelog) (#41104)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
952b1e73b2
commit
b2b2a8641a
|
|
@ -0,0 +1,17 @@
|
|||
import { skillNameKey } from '../skill-names';
|
||||
|
||||
describe('skillNameKey', () => {
|
||||
it.each([
|
||||
['Brand voice', 'brand-voice'],
|
||||
['Brand voice', 'brand_voice'],
|
||||
['Brand voice', ' BRAND voice '],
|
||||
['Brand voice', 'brand -_ voice'],
|
||||
])('treats "%s" and "%s" as the same name', (left, right) => {
|
||||
expect(skillNameKey(left)).toBe(skillNameKey(right));
|
||||
});
|
||||
|
||||
it('keeps other characters apart', () => {
|
||||
expect(skillNameKey('Brand voice')).not.toBe(skillNameKey('Brand voices'));
|
||||
expect(skillNameKey('brand.voice')).not.toBe(skillNameKey('brand voice'));
|
||||
});
|
||||
});
|
||||
|
|
@ -8,6 +8,7 @@ export * from './agent-node-tool-operations';
|
|||
export * from './agent-personalisation';
|
||||
export * from './agent-skill.schema';
|
||||
export * from './skills.schema';
|
||||
export * from './skill-names';
|
||||
export * from './child-trace';
|
||||
export type * from './background-job';
|
||||
export * from './inline-agent-config.schema';
|
||||
|
|
|
|||
11
packages/@n8n/api-types/src/agents/skill-names.ts
Normal file
11
packages/@n8n/api-types/src/agents/skill-names.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
/**
|
||||
* The rule for two skill names that are hard to tell apart inside one agent: case,
|
||||
* surrounding spaces, and runs of spaces, hyphens and underscores do not count.
|
||||
*/
|
||||
export function skillNameKey(name: string): string {
|
||||
return name
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[\s_-]+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
|
@ -2,25 +2,8 @@ import {
|
|||
findIntroducedNameClashes,
|
||||
findNameClashGroups,
|
||||
renameIntroducesClash,
|
||||
skillNameKey,
|
||||
} from '../skills/skill-names';
|
||||
|
||||
describe('skillNameKey', () => {
|
||||
it.each([
|
||||
['Brand voice', 'brand-voice'],
|
||||
['Brand voice', 'brand_voice'],
|
||||
['Brand voice', ' BRAND voice '],
|
||||
['Brand voice', 'brand -_ voice'],
|
||||
])('treats "%s" and "%s" as the same name', (left, right) => {
|
||||
expect(skillNameKey(left)).toBe(skillNameKey(right));
|
||||
});
|
||||
|
||||
it('keeps other characters apart', () => {
|
||||
expect(skillNameKey('Brand voice')).not.toBe(skillNameKey('Brand voices'));
|
||||
expect(skillNameKey('brand.voice')).not.toBe(skillNameKey('brand voice'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('findIntroducedNameClashes', () => {
|
||||
it('returns an added name that clashes with an existing name', () => {
|
||||
expect(findIntroducedNameClashes(['Brand voice'], ['brand-voice', 'Pricing'])).toEqual([
|
||||
|
|
|
|||
|
|
@ -1,14 +1,4 @@
|
|||
/**
|
||||
* The rule for two skill names that are hard to tell apart inside one agent: case,
|
||||
* surrounding spaces, and runs of spaces, hyphens and underscores do not count.
|
||||
*/
|
||||
export function skillNameKey(name: string): string {
|
||||
return name
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[\s_-]+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
import { skillNameKey } from '@n8n/api-types';
|
||||
|
||||
/**
|
||||
* Names in `added` that clash with a name in `existing` or with an earlier added name.
|
||||
|
|
|
|||
|
|
@ -8851,8 +8851,7 @@
|
|||
"agents.builder.tools.type.custom": "Custom tool",
|
||||
"agents.builder.tools.remove": "Remove",
|
||||
"agents.builder.tools.back": "Back to tools",
|
||||
"agents.builder.skills.outdated.title": "This agent uses an older version",
|
||||
"agents.builder.skills.outdated.warning": "This agent uses an older version of this skill. To get the latest version, remove the skill and add it again.",
|
||||
"agents.builder.skills.outdated.warning": "This agent uses an older version of this skill. Open the skill to use the latest version.",
|
||||
"agents.builder.skills.title": "Skills",
|
||||
"agents.builder.skills.description": "How this agent handles specific tasks",
|
||||
"agents.builder.skills.count": "{count} skill applied | {count} skills applied",
|
||||
|
|
@ -8910,17 +8909,42 @@
|
|||
"agents.builder.skills.references.characterCount": "{count} / {max} characters",
|
||||
"agents.builder.skills.create.error": "Could not create skill",
|
||||
"agents.builder.skills.saveError": "Could not save skill",
|
||||
"agents.builder.skills.duplicateName.error": "Agent already has a skill named \"{name}\"",
|
||||
"agents.builder.skills.duplicateName.error": "Agent already has a skill with a name like \"{name}\"",
|
||||
"agents.builder.skills.validation.nameRequired": "Enter a name.",
|
||||
"agents.builder.skills.validation.nameMaxLength": "Name must be 128 characters or fewer.",
|
||||
"agents.builder.skills.validation.nameDuplicate": "Agent already has a skill with this name.",
|
||||
"agents.builder.skills.validation.nameDuplicate": "Agent already has a skill with a name like this.",
|
||||
"agents.builder.skills.validation.descriptionRequired": "Enter a description.",
|
||||
"agents.builder.skills.validation.descriptionMaxLength": "Description must be 512 characters or fewer.",
|
||||
"agents.builder.skills.validation.instructionsRequired": "Enter instructions.",
|
||||
"agents.builder.skills.validation.instructionsMaxLength": "Instructions must be {max} characters or fewer.",
|
||||
"agents.builder.skills.missingContent.callout": "This skill's content is missing. Fill it in and save to recreate it.",
|
||||
"agents.builder.skills.remove": "Remove skill",
|
||||
"agents.builder.skills.detach": "Detach",
|
||||
"agents.builder.skills.back": "Back to skills",
|
||||
"agents.builder.skills.picker.search.placeholder": "Search skills",
|
||||
"agents.builder.skills.picker.create": "Create skill",
|
||||
"agents.builder.skills.picker.create.description": "Write a new skill for this agent",
|
||||
"agents.builder.skills.picker.add": "Add skill",
|
||||
"agents.builder.skills.picker.loading": "Loading skills…",
|
||||
"agents.builder.skills.picker.empty": "No skills to add. Create one, or ask an admin to share one with this project.",
|
||||
"agents.builder.skills.picker.noResults": "No skills match your search",
|
||||
"agents.builder.skills.picker.loadError": "Could not load skills",
|
||||
"agents.builder.skills.picker.previewError": "Could not load the skill",
|
||||
"agents.builder.skills.accessError": "Could not check if you can edit this skill",
|
||||
"agents.builder.skills.picker.similarName": "Skills {names} have names that are hard to tell apart. Rename one of them.",
|
||||
"agents.builder.skills.attach.error": "Could not add skill",
|
||||
"agents.builder.skills.availableTo.label": "Where can this skill be added?",
|
||||
"agents.builder.skills.availableTo.tooltip": "This project: agents in this project can add it. This agent only: only this agent can use it. Global: agents in all projects can add it.",
|
||||
"agents.builder.skills.availableTo.project": "This project",
|
||||
"agents.builder.skills.availableTo.agent": "This agent only",
|
||||
"agents.builder.skills.availableTo.instance": "Global",
|
||||
"agents.builder.skills.availableTo.locked": "Other agents use this skill. Detach it from those agents to change where it can be added. For agents that you cannot open, ask an admin.",
|
||||
"agents.builder.skills.hint.addToEdit": "Add this skill to the agent to edit it.",
|
||||
"agents.builder.skills.hint.outdated": "This agent uses an older version of this skill.",
|
||||
"agents.builder.skills.useLatest": "Use latest version",
|
||||
"agents.builder.skills.useLatest.error": "Could not switch to the latest version",
|
||||
"agents.builder.skills.hint.readOnly.instance": "You can't edit this skill. Ask an instance admin to change it.",
|
||||
"agents.builder.skills.hint.readOnly.project": "You can't edit this skill. Ask a project admin to change it.",
|
||||
"agents.builder.tasks.title": "Schedules",
|
||||
"agents.builder.tasks.description": "Objectives the agent runs on a schedule once it's published.",
|
||||
"agents.builder.tasks.add": "Add schedule",
|
||||
|
|
@ -9432,9 +9456,14 @@
|
|||
"tools.connection.categories.community": "Community",
|
||||
"tools.connection.categories.workflows": "Workflows",
|
||||
"tools.connection.categories.agents": "Agents",
|
||||
"tools.connection.categories.project": "Project",
|
||||
"tools.connection.categories.thisAgent": "This agent",
|
||||
"tools.connection.categories.global": "Global",
|
||||
"tools.connection.categories.data": "Data",
|
||||
"tools.connection.action.connect": "Connect",
|
||||
"tools.connection.action.connected": "Connected",
|
||||
"tools.connection.action.attached": "Attached",
|
||||
"tools.connection.action.detached": "Detached",
|
||||
"tools.connection.action.connecting": "Connecting…",
|
||||
"tools.connection.action.reconnect": "Reconnect",
|
||||
"tools.connection.action.configure": "Configure",
|
||||
|
|
|
|||
|
|
@ -5658,6 +5658,7 @@ describe('AgentBuilderView — three-column shell', () => {
|
|||
'summarize_notes',
|
||||
updatedSkill,
|
||||
'skill-hash-1',
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -5710,6 +5711,7 @@ describe('AgentBuilderView — three-column shell', () => {
|
|||
instructions: skill.instructions,
|
||||
},
|
||||
'skill-hash-1',
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { ref } from 'vue';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { mockedStore } from '@/__tests__/utils';
|
||||
|
|
@ -7,9 +8,11 @@ import { configure, fireEvent, waitFor } from '@testing-library/vue';
|
|||
import {
|
||||
AGENT_SKILL_INSTRUCTIONS_MAX_LENGTH,
|
||||
AGENT_SKILL_REFERENCE_MAX_COUNT,
|
||||
type SkillDetail,
|
||||
type SkillListItem,
|
||||
} from '@n8n/api-types';
|
||||
|
||||
import AgentSkillModal from '../components/AgentSkillModal.vue';
|
||||
import AgentSkillModal, { type AgentSkillModalData } from '../components/AgentSkillModal.vue';
|
||||
import type { AgentSkill } from '../types';
|
||||
import { AgentModalTestStub } from './utils/AgentModalTestStub';
|
||||
|
||||
|
|
@ -23,16 +26,52 @@ vi.mock('@n8n/i18n', () => {
|
|||
});
|
||||
|
||||
const apiCreateSpy = vi.fn();
|
||||
const { listAttachableSkills, getHubSkill } = vi.hoisted(() => ({
|
||||
listAttachableSkills: vi.fn(),
|
||||
getHubSkill: vi.fn(),
|
||||
}));
|
||||
vi.mock('../composables/useAgentApi', () => ({
|
||||
createAgentSkill: (...args: unknown[]) => apiCreateSpy(...args),
|
||||
listAttachableSkills,
|
||||
getHubSkill,
|
||||
}));
|
||||
|
||||
const { showMessage, trackImportedSkill } = vi.hoisted(() => ({
|
||||
const { showMessage, showError, trackImportedSkill } = vi.hoisted(() => ({
|
||||
showMessage: vi.fn(),
|
||||
showError: vi.fn(),
|
||||
trackImportedSkill: vi.fn(),
|
||||
}));
|
||||
vi.mock('@n8n/composables/useToast', () => ({
|
||||
useToast: () => ({ showMessage }),
|
||||
useToast: () => ({ showMessage, showError }),
|
||||
}));
|
||||
|
||||
vi.mock('@/features/shared/toolsConnection/ToolsConnectionModal.vue', () => ({
|
||||
default: {
|
||||
name: 'ToolsConnectionModal',
|
||||
props: ['items', 'emptyMessage', 'createAction', 'categories', 'categoryLabels'],
|
||||
emits: ['connect', 'disconnect', 'open-detail', 'create'],
|
||||
template: `
|
||||
<div data-testid="agent-skill-picker">
|
||||
<span data-testid="agent-skill-picker-tabs">{{ categories.join(',') }} {{ categoryLabels?.project }}</span>
|
||||
<button data-testid="agent-skill-picker-create" @click="$emit('create', 'all')">
|
||||
{{ createAction.label }}
|
||||
</button>
|
||||
<button
|
||||
v-for="category in categories"
|
||||
:key="category"
|
||||
:data-testid="'agent-skill-picker-create-' + category"
|
||||
@click="$emit('create', category)"
|
||||
/>
|
||||
<div v-if="items.length === 0" data-testid="agent-skill-picker-empty">{{ emptyMessage }}</div>
|
||||
<div v-for="item in items" :key="item.id" data-testid="agent-skill-picker-row">
|
||||
<button data-testid="agent-skill-picker-row-open" @click="$emit('open-detail', item)">{{ item.title }}</button>
|
||||
<span data-testid="agent-skill-picker-category">{{ item.category }}</span>
|
||||
<span v-if="item.disabled" data-testid="agent-skill-picker-disabled">{{ item.disabledReason }}</span>
|
||||
<button v-else data-testid="agent-skill-picker-toggle" :data-attached="String(item.attached)" @click="$emit(item.attached ? 'disconnect' : 'connect', item)" />
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../composables/useAgentTelemetry', () => ({
|
||||
|
|
@ -46,12 +85,23 @@ function renderModal({
|
|||
skill,
|
||||
skillId,
|
||||
existingSkillNames,
|
||||
...hubOptions
|
||||
}: {
|
||||
onConfirm?: (payload: { id?: string; skill: AgentSkill }) => void;
|
||||
onConfirm?: AgentSkillModalData['onConfirm'];
|
||||
skill?: AgentSkill;
|
||||
skillId?: string;
|
||||
existingSkillNames?: string[];
|
||||
} = {}) {
|
||||
} & Pick<
|
||||
AgentSkillModalData,
|
||||
| 'hubSkillsEnabled'
|
||||
| 'getAttachedSkills'
|
||||
| 'projectLabel'
|
||||
| 'canCreateInstanceSkills'
|
||||
| 'onAttach'
|
||||
| 'onRemove'
|
||||
| 'outdated'
|
||||
| 'onUseLatestVersion'
|
||||
> = {}) {
|
||||
const renderComponent = createComponentRenderer(AgentSkillModal, {
|
||||
global: {
|
||||
stubs: {
|
||||
|
|
@ -82,6 +132,7 @@ function renderModal({
|
|||
skillId,
|
||||
existingSkillNames,
|
||||
onConfirm,
|
||||
...hubOptions,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -193,7 +244,7 @@ describe('AgentSkillModal', () => {
|
|||
existingSkillNames: ['Other skill'],
|
||||
});
|
||||
|
||||
await fireEvent.update(getTitleInput(container), ' other SKILL ');
|
||||
await fireEvent.update(getTitleInput(container), ' other_SKILL ');
|
||||
expect(queryByTestId('agent-modal-title-error')).not.toBeInTheDocument();
|
||||
await fireEvent.click(getByTestId('agent-skill-create-save'));
|
||||
|
||||
|
|
@ -380,8 +431,597 @@ describe('AgentSkillModal', () => {
|
|||
});
|
||||
expect(uiStore.closeModal).toHaveBeenCalledWith(MODAL_NAME);
|
||||
});
|
||||
|
||||
it('creates a skill on the agent without the picker or the skills API when the skills hub is off', async () => {
|
||||
const onConfirm = vi.fn();
|
||||
const { container, getByTestId, queryByTestId } = renderModal({
|
||||
hubSkillsEnabled: false,
|
||||
onAttach: vi.fn(),
|
||||
onConfirm,
|
||||
});
|
||||
|
||||
expect(queryByTestId('agent-skill-picker')).not.toBeInTheDocument();
|
||||
expect(getByTestId('agent-skill-upload')).toBeInTheDocument();
|
||||
await fireEvent.click(getByTestId('agent-skill-add-manually'));
|
||||
expect(queryByTestId('agent-skill-availability-select')).not.toBeInTheDocument();
|
||||
await fireEvent.update(getTitleInput(container), 'Research');
|
||||
await fireEvent.update(
|
||||
getByTestId('agent-skill-description-input').querySelector('input')!,
|
||||
'Use for research',
|
||||
);
|
||||
await fireEvent.update(getByTestId('agent-skill-instructions-editor'), 'Read the guide');
|
||||
await fireEvent.click(getByTestId('agent-skill-create-save'));
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledWith(
|
||||
expect.not.objectContaining({ availableTo: expect.anything() }),
|
||||
);
|
||||
expect(listAttachableSkills).not.toHaveBeenCalled();
|
||||
expect(getHubSkill).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('with the skills hub', () => {
|
||||
const projectSkill = makeListItem({ id: 'tone', name: 'Support tone', scope: 'project' });
|
||||
const instanceSkill = makeListItem({ id: 'sql', name: 'SQL helper', scope: 'instance' });
|
||||
const similarSkill = makeListItem({ id: 'voice', name: 'brand-voice', scope: 'instance' });
|
||||
const agentSkill = makeListItem({
|
||||
id: 'notes',
|
||||
name: 'Call notes',
|
||||
scope: 'project',
|
||||
agentId: 'a1',
|
||||
});
|
||||
|
||||
it('lists attachable skills in project, agent and global tabs and attaches one without closing', async () => {
|
||||
listAttachableSkills.mockResolvedValue([projectSkill, agentSkill, instanceSkill]);
|
||||
const onAttach = vi.fn().mockResolvedValue(undefined);
|
||||
const { findAllByTestId, getAllByTestId, getByTestId, queryByTestId } = renderModal({
|
||||
hubSkillsEnabled: true,
|
||||
projectLabel: 'Marketing',
|
||||
onAttach,
|
||||
});
|
||||
|
||||
expect(listAttachableSkills).toHaveBeenCalledWith(expect.anything(), 'a1');
|
||||
expect(await findAllByTestId('agent-skill-picker-row')).toHaveLength(3);
|
||||
const picker = getByTestId('agent-skill-picker');
|
||||
expect(getAllByTestId('agent-skill-picker-category').map((node) => node.textContent)).toEqual(
|
||||
['project', 'this-agent', 'global'],
|
||||
);
|
||||
expect(queryByTestId('agent-skill-upload')).not.toBeInTheDocument();
|
||||
expect(queryByTestId('agent-skill-create-save')).not.toBeInTheDocument();
|
||||
|
||||
await fireEvent.click(getAllByTestId('agent-skill-picker-toggle')[2]);
|
||||
|
||||
expect(onAttach).toHaveBeenCalledWith(instanceSkill);
|
||||
expect(uiStore.closeModal).not.toHaveBeenCalled();
|
||||
expect(picker).toBeVisible();
|
||||
});
|
||||
|
||||
it('names the project tab after the project of the agent', async () => {
|
||||
listAttachableSkills.mockResolvedValue([]);
|
||||
const { getByTestId } = renderModal({
|
||||
hubSkillsEnabled: true,
|
||||
projectLabel: 'Marketing',
|
||||
onAttach: vi.fn(),
|
||||
});
|
||||
|
||||
expect(getByTestId('agent-skill-picker-tabs')).toHaveTextContent(
|
||||
'all,project,this-agent,global Marketing',
|
||||
);
|
||||
});
|
||||
|
||||
it('shows a skill as attached while it attaches', async () => {
|
||||
listAttachableSkills.mockResolvedValue([instanceSkill]);
|
||||
let finishAttach = () => {};
|
||||
const onAttach = vi.fn(
|
||||
async () => await new Promise<void>((resolve) => (finishAttach = resolve)),
|
||||
);
|
||||
const { findByTestId } = renderModal({ hubSkillsEnabled: true, onAttach });
|
||||
|
||||
const toggle = await findByTestId('agent-skill-picker-toggle');
|
||||
await fireEvent.click(toggle);
|
||||
|
||||
expect(await findByTestId('agent-skill-picker-toggle')).toHaveAttribute(
|
||||
'data-attached',
|
||||
'true',
|
||||
);
|
||||
finishAttach();
|
||||
await waitFor(async () =>
|
||||
expect(await findByTestId('agent-skill-picker-toggle')).toHaveAttribute(
|
||||
'data-attached',
|
||||
'false',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('detaches an attached skill without closing and refuses names that are hard to tell apart', async () => {
|
||||
listAttachableSkills.mockResolvedValue([projectSkill, similarSkill]);
|
||||
const onAttach = vi.fn();
|
||||
const onRemove = vi.fn();
|
||||
const { findAllByTestId, getByTestId } = renderModal({
|
||||
hubSkillsEnabled: true,
|
||||
getAttachedSkills: () => [
|
||||
{ id: 'tone', name: 'Support tone' },
|
||||
{ id: 'other', name: 'Brand voice' },
|
||||
],
|
||||
onAttach,
|
||||
onRemove,
|
||||
});
|
||||
|
||||
expect(await findAllByTestId('agent-skill-picker-row')).toHaveLength(2);
|
||||
expect(getByTestId('agent-skill-picker-disabled')).toHaveTextContent(
|
||||
'agents.builder.skills.picker.similarName',
|
||||
);
|
||||
const toggle = getByTestId('agent-skill-picker-toggle');
|
||||
expect(toggle).toHaveAttribute('data-attached', 'true');
|
||||
|
||||
await fireEvent.click(toggle);
|
||||
|
||||
expect(onRemove).toHaveBeenCalledWith('tone');
|
||||
expect(onAttach).not.toHaveBeenCalled();
|
||||
expect(uiStore.closeModal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows an empty message when no skill can be attached', async () => {
|
||||
listAttachableSkills.mockResolvedValue([]);
|
||||
const { findByTestId } = renderModal({ hubSkillsEnabled: true, onAttach: vi.fn() });
|
||||
|
||||
await waitFor(async () =>
|
||||
expect(await findByTestId('agent-skill-picker-empty')).toHaveTextContent(
|
||||
'agents.builder.skills.picker.empty',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('goes from the picker to the upload step and back', async () => {
|
||||
listAttachableSkills.mockResolvedValue([projectSkill]);
|
||||
const { getByTestId, queryByTestId } = renderModal({
|
||||
hubSkillsEnabled: true,
|
||||
onAttach: vi.fn(),
|
||||
});
|
||||
|
||||
await fireEvent.click(getByTestId('agent-skill-picker-create'));
|
||||
expect(getByTestId('agent-skill-upload')).toBeInTheDocument();
|
||||
|
||||
await fireEvent.click(getByTestId('agent-modal-back'));
|
||||
expect(queryByTestId('agent-skill-upload')).not.toBeInTheDocument();
|
||||
expect(getByTestId('agent-skill-picker')).toBeVisible();
|
||||
});
|
||||
|
||||
it('opens a read-only preview from a row click and adds the skill from there', async () => {
|
||||
listAttachableSkills.mockResolvedValue([projectSkill, instanceSkill]);
|
||||
getHubSkill.mockResolvedValue(
|
||||
makeDetail({ id: 'sql', name: 'SQL helper', scope: 'instance', canEdit: true }),
|
||||
);
|
||||
const onAttach = vi.fn();
|
||||
const { findAllByTestId, findByTestId, getByTestId, queryByTestId } = renderModal({
|
||||
hubSkillsEnabled: true,
|
||||
onAttach,
|
||||
});
|
||||
|
||||
await fireEvent.click((await findAllByTestId('agent-skill-picker-row-open'))[1]);
|
||||
|
||||
expect(await findByTestId('agent-skill-preview')).toBeInTheDocument();
|
||||
expect(getHubSkill).toHaveBeenCalledWith(expect.anything(), 'sql');
|
||||
expect(getByTestId('agent-skill-instructions-text')).toHaveTextContent('Instructions');
|
||||
expect(queryByTestId('agent-skill-instructions-editor')).not.toBeInTheDocument();
|
||||
expect(getByTestId('agent-skill-footer-hint')).toHaveTextContent(
|
||||
'agents.builder.skills.hint.addToEdit',
|
||||
);
|
||||
expect(queryByTestId('agent-modal-title-input')).not.toBeInTheDocument();
|
||||
expect(queryByTestId('agent-skill-add-reference')).not.toBeInTheDocument();
|
||||
expect(queryByTestId('agent-skill-create-save')).not.toBeInTheDocument();
|
||||
expect(queryByTestId('agent-skill-remove')).not.toBeInTheDocument();
|
||||
expect(onAttach).not.toHaveBeenCalled();
|
||||
|
||||
await fireEvent.click(getByTestId('agent-skill-preview-add'));
|
||||
|
||||
expect(onAttach).toHaveBeenCalledWith(instanceSkill);
|
||||
expect(uiStore.closeModal).not.toHaveBeenCalled();
|
||||
expect(queryByTestId('agent-skill-preview')).not.toBeInTheDocument();
|
||||
expect(getByTestId('agent-skill-picker')).toBeVisible();
|
||||
});
|
||||
|
||||
it('offers to remove an attached skill from its preview', async () => {
|
||||
listAttachableSkills.mockResolvedValue([projectSkill]);
|
||||
getHubSkill.mockResolvedValue(
|
||||
makeDetail({ id: 'tone', name: 'Support tone', scope: 'project' }),
|
||||
);
|
||||
const onRemove = vi.fn();
|
||||
const { findByTestId, getByTestId, queryByTestId } = renderModal({
|
||||
hubSkillsEnabled: true,
|
||||
getAttachedSkills: () => [{ id: 'tone', name: 'Support tone' }],
|
||||
onAttach: vi.fn(),
|
||||
onRemove,
|
||||
});
|
||||
|
||||
await fireEvent.click(await findByTestId('agent-skill-picker-row-open'));
|
||||
await findByTestId('agent-skill-preview');
|
||||
|
||||
expect(queryByTestId('agent-skill-preview-add')).not.toBeInTheDocument();
|
||||
expect(queryByTestId('agent-skill-footer-hint')).not.toBeInTheDocument();
|
||||
await fireEvent.click(getByTestId('agent-skill-remove'));
|
||||
|
||||
expect(onRemove).toHaveBeenCalledWith('tone');
|
||||
expect(uiStore.closeModal).not.toHaveBeenCalled();
|
||||
expect(queryByTestId('agent-skill-preview')).not.toBeInTheDocument();
|
||||
expect(getByTestId('agent-skill-picker')).toBeVisible();
|
||||
});
|
||||
|
||||
it('returns from the preview to the picker', async () => {
|
||||
listAttachableSkills.mockResolvedValue([projectSkill]);
|
||||
getHubSkill.mockResolvedValue(
|
||||
makeDetail({ id: 'tone', name: 'Support tone', scope: 'project' }),
|
||||
);
|
||||
const { findByTestId, getByTestId, queryByTestId } = renderModal({
|
||||
hubSkillsEnabled: true,
|
||||
onAttach: vi.fn(),
|
||||
});
|
||||
|
||||
await fireEvent.click(await findByTestId('agent-skill-picker-row-open'));
|
||||
await findByTestId('agent-skill-preview');
|
||||
await fireEvent.click(getByTestId('agent-modal-back'));
|
||||
|
||||
expect(queryByTestId('agent-skill-preview')).not.toBeInTheDocument();
|
||||
expect(getByTestId('agent-skill-picker')).toBeVisible();
|
||||
});
|
||||
|
||||
it('stays on the picker when the preview cannot load', async () => {
|
||||
listAttachableSkills.mockResolvedValue([projectSkill]);
|
||||
getHubSkill.mockRejectedValue(new Error('Not found'));
|
||||
const { findByTestId, getByTestId, queryByTestId } = renderModal({
|
||||
hubSkillsEnabled: true,
|
||||
onAttach: vi.fn(),
|
||||
});
|
||||
|
||||
await fireEvent.click(await findByTestId('agent-skill-picker-row-open'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(showError).toHaveBeenCalledWith(
|
||||
expect.any(Error),
|
||||
'agents.builder.skills.picker.previewError',
|
||||
),
|
||||
);
|
||||
expect(queryByTestId('agent-skill-preview')).not.toBeInTheDocument();
|
||||
expect(getByTestId('agent-skill-picker')).toBeVisible();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['project', 'agents.builder.skills.availableTo.project'],
|
||||
['instance', 'agents.builder.skills.availableTo.instance'],
|
||||
] as const)(
|
||||
'saves a new skill for the %s from the availability select',
|
||||
async (availableTo, label) => {
|
||||
listAttachableSkills.mockResolvedValue([]);
|
||||
const onConfirm = vi.fn();
|
||||
const { container, getByTestId, findByText } = renderModal({
|
||||
hubSkillsEnabled: true,
|
||||
canCreateInstanceSkills: true,
|
||||
projectLabel: 'Marketing',
|
||||
onAttach: vi.fn(),
|
||||
onConfirm,
|
||||
});
|
||||
|
||||
await fireEvent.click(getByTestId('agent-skill-picker-create'));
|
||||
await fireEvent.click(getByTestId('agent-skill-add-manually'));
|
||||
await fireEvent.update(getTitleInput(container), 'Research');
|
||||
await fireEvent.update(
|
||||
getByTestId('agent-skill-description-input').querySelector('input')!,
|
||||
'Use for research',
|
||||
);
|
||||
await fireEvent.update(getByTestId('agent-skill-instructions-editor'), 'Read the guide');
|
||||
await fireEvent.click(
|
||||
getByTestId('agent-skill-availability-select').querySelector('.select-trigger')!,
|
||||
);
|
||||
await fireEvent.click(await findByText(label));
|
||||
await fireEvent.click(getByTestId('agent-skill-create-save'));
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: undefined, availableTo }),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
['all', true, 'agent'],
|
||||
['project', true, 'project'],
|
||||
['this-agent', true, 'agent'],
|
||||
['global', true, 'instance'],
|
||||
['global', false, 'agent'],
|
||||
] as const)(
|
||||
'starts a new skill from the %s tab with a matching target (global allowed: %s)',
|
||||
async (category, canCreateInstanceSkills, availableTo) => {
|
||||
listAttachableSkills.mockResolvedValue([]);
|
||||
const onConfirm = vi.fn();
|
||||
const { container, getByTestId } = renderModal({
|
||||
hubSkillsEnabled: true,
|
||||
canCreateInstanceSkills,
|
||||
onAttach: vi.fn(),
|
||||
onConfirm,
|
||||
});
|
||||
|
||||
await fireEvent.click(getByTestId(`agent-skill-picker-create-${category}`));
|
||||
await fireEvent.click(getByTestId('agent-skill-add-manually'));
|
||||
await fireEvent.update(getTitleInput(container), 'Research');
|
||||
await fireEvent.update(
|
||||
getByTestId('agent-skill-description-input').querySelector('input')!,
|
||||
'Use for research',
|
||||
);
|
||||
await fireEvent.update(getByTestId('agent-skill-instructions-editor'), 'Read the guide');
|
||||
await fireEvent.click(getByTestId('agent-skill-create-save'));
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledWith(expect.objectContaining({ availableTo }));
|
||||
},
|
||||
);
|
||||
|
||||
it('checks a new skill name against the skills attached after the picker opened', async () => {
|
||||
listAttachableSkills.mockResolvedValue([]);
|
||||
const attached = ref<Array<{ id: string; name: string }>>([]);
|
||||
const onConfirm = vi.fn();
|
||||
const { container, getByTestId } = renderModal({
|
||||
hubSkillsEnabled: true,
|
||||
onAttach: vi.fn(),
|
||||
getAttachedSkills: () => attached.value,
|
||||
onConfirm,
|
||||
});
|
||||
|
||||
attached.value = [{ id: 'brand', name: 'P-Brand voice' }];
|
||||
await fireEvent.click(getByTestId('agent-skill-picker-create'));
|
||||
await fireEvent.click(getByTestId('agent-skill-add-manually'));
|
||||
await fireEvent.update(getTitleInput(container), 'p brand voice');
|
||||
await fireEvent.update(
|
||||
getByTestId('agent-skill-description-input').querySelector('input')!,
|
||||
'Use for replies',
|
||||
);
|
||||
await fireEvent.update(getByTestId('agent-skill-instructions-editor'), 'Be kind');
|
||||
await fireEvent.click(getByTestId('agent-skill-create-save'));
|
||||
|
||||
expect(onConfirm).not.toHaveBeenCalled();
|
||||
expect(getByTestId('agent-modal-title-error')).toHaveTextContent(
|
||||
'agents.builder.skills.validation.nameDuplicate',
|
||||
);
|
||||
});
|
||||
|
||||
it('offers only this project and this agent to a user who may not create global skills', async () => {
|
||||
listAttachableSkills.mockResolvedValue([]);
|
||||
const { getByTestId, findByText, queryByText } = renderModal({
|
||||
hubSkillsEnabled: true,
|
||||
onAttach: vi.fn(),
|
||||
});
|
||||
|
||||
await fireEvent.click(getByTestId('agent-skill-picker-create'));
|
||||
await fireEvent.click(getByTestId('agent-skill-add-manually'));
|
||||
await fireEvent.click(
|
||||
getByTestId('agent-skill-availability-select').querySelector('.select-trigger')!,
|
||||
);
|
||||
|
||||
expect(await findByText('agents.builder.skills.availableTo.agent')).toBeInTheDocument();
|
||||
expect(queryByText('agents.builder.skills.availableTo.instance')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps a skill read-only and reports the error when its access check fails', async () => {
|
||||
const error = new Error('offline');
|
||||
getHubSkill.mockRejectedValue(error);
|
||||
const { findByTestId, queryByTestId } = renderModal({
|
||||
hubSkillsEnabled: true,
|
||||
skillId: 'sql',
|
||||
skill: { name: 'SQL helper', description: 'Use for SQL', instructions: 'Write SQL' },
|
||||
onRemove: vi.fn(),
|
||||
});
|
||||
|
||||
await findByTestId('agent-skill-remove');
|
||||
await vi.waitFor(() =>
|
||||
expect(showError).toHaveBeenCalledWith(error, 'agents.builder.skills.accessError'),
|
||||
);
|
||||
expect(queryByTestId('agent-skill-create-save')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows a skill that the user cannot edit as read-only', async () => {
|
||||
getHubSkill.mockResolvedValue(
|
||||
makeDetail({ id: 'sql', name: 'SQL helper', scope: 'instance', canEdit: false }),
|
||||
);
|
||||
const onRemove = vi.fn();
|
||||
const { findByTestId, getByTestId, queryByTestId } = renderModal({
|
||||
hubSkillsEnabled: true,
|
||||
skillId: 'sql',
|
||||
skill: { name: 'SQL helper', description: 'Use for SQL', instructions: 'Write SQL' },
|
||||
onRemove,
|
||||
});
|
||||
|
||||
expect(await findByTestId('agent-skill-footer-hint')).toHaveTextContent(
|
||||
'agents.builder.skills.hint.readOnly.instance',
|
||||
);
|
||||
expect(getByTestId('agent-skill-description-text')).toHaveTextContent('Use for SQL');
|
||||
expect(queryByTestId('agent-skill-description-input')).not.toBeInTheDocument();
|
||||
expect(getHubSkill).toHaveBeenCalledWith(expect.anything(), 'sql');
|
||||
expect(queryByTestId('agent-modal-title-input')).not.toBeInTheDocument();
|
||||
expect(queryByTestId('agent-skill-create-save')).not.toBeInTheDocument();
|
||||
expect(queryByTestId('agent-skill-add-reference')).not.toBeInTheDocument();
|
||||
|
||||
await fireEvent.click(getByTestId('agent-skill-remove'));
|
||||
expect(onRemove).toHaveBeenCalledWith('sql');
|
||||
});
|
||||
|
||||
describe('where an existing skill can be added', () => {
|
||||
const tone = {
|
||||
name: 'Support tone',
|
||||
description: 'Use for replies',
|
||||
instructions: 'Be kind',
|
||||
};
|
||||
|
||||
async function renderEdit(detail: SkillDetail, onConfirm = vi.fn()) {
|
||||
getHubSkill.mockResolvedValue(detail);
|
||||
const result = renderModal({
|
||||
hubSkillsEnabled: true,
|
||||
skillId: detail.id,
|
||||
skill: tone,
|
||||
onConfirm,
|
||||
onRemove: vi.fn(),
|
||||
});
|
||||
await result.findByTestId('agent-skill-availability-select');
|
||||
return result;
|
||||
}
|
||||
|
||||
it('enables save only after the skill or its target changes', async () => {
|
||||
const { getByTestId } = await renderEdit(
|
||||
makeDetail({ id: 'tone', name: 'Support tone', scope: 'project' }),
|
||||
);
|
||||
|
||||
expect(getByTestId('agent-skill-create-save')).toBeDisabled();
|
||||
|
||||
await fireEvent.update(
|
||||
getByTestId('agent-skill-description-input').querySelector('input')!,
|
||||
'Use for all replies',
|
||||
);
|
||||
|
||||
expect(getByTestId('agent-skill-create-save')).toBeEnabled();
|
||||
});
|
||||
|
||||
it('changes the target of a skill that no other agent uses', async () => {
|
||||
const onConfirm = vi.fn();
|
||||
const { getByTestId, findByText } = await renderEdit(
|
||||
{
|
||||
...makeDetail({ id: 'tone', name: 'Support tone', scope: 'project' }),
|
||||
usedBy: {
|
||||
drafts: [{ agentId: 'a1', agentName: 'This agent', projectId: 'p1' }],
|
||||
pins: [{ agentId: 'a1', agentName: 'This agent', projectId: 'p1', version: 1 }],
|
||||
hiddenAgents: 0,
|
||||
},
|
||||
},
|
||||
onConfirm,
|
||||
);
|
||||
|
||||
const select = getByTestId('agent-skill-availability-select');
|
||||
await fireEvent.click(select.querySelector('.select-trigger')!);
|
||||
await fireEvent.click(await findByText('agents.builder.skills.availableTo.agent'));
|
||||
await fireEvent.click(getByTestId('agent-skill-create-save'));
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'tone', availableTo: 'agent' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not send a target when the user keeps it', async () => {
|
||||
const onConfirm = vi.fn();
|
||||
const { getByTestId } = await renderEdit(
|
||||
makeDetail({ id: 'tone', name: 'Support tone', scope: 'project', agentId: 'a1' }),
|
||||
onConfirm,
|
||||
);
|
||||
|
||||
await fireEvent.click(getByTestId('agent-skill-create-save'));
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledWith(
|
||||
expect.not.objectContaining({ availableTo: expect.anything() }),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
'a draft of another agent',
|
||||
{
|
||||
drafts: [{ agentId: 'a2', agentName: 'Other', projectId: 'p1' }],
|
||||
pins: [],
|
||||
hiddenAgents: 0,
|
||||
},
|
||||
],
|
||||
[
|
||||
'a pin of another agent',
|
||||
{
|
||||
drafts: [],
|
||||
pins: [{ agentId: 'a2', agentName: 'Other', projectId: 'p1', version: null }],
|
||||
hiddenAgents: 0,
|
||||
},
|
||||
],
|
||||
['an agent the user cannot see', { drafts: [], pins: [], hiddenAgents: 1 }],
|
||||
])('locks the target when %s uses the skill', async (_case, usedBy) => {
|
||||
const { getByTestId } = await renderEdit({
|
||||
...makeDetail({ id: 'tone', name: 'Support tone', scope: 'project' }),
|
||||
usedBy,
|
||||
});
|
||||
|
||||
expect(
|
||||
getByTestId('agent-skill-availability-select').querySelector('.is-disabled, [disabled]'),
|
||||
).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows a skill on an older version as read-only with a switch to the latest version', async () => {
|
||||
getHubSkill.mockResolvedValue(
|
||||
makeDetail({ id: 'tone', name: 'Support tone', scope: 'project', canEdit: true }),
|
||||
);
|
||||
const onUseLatestVersion = vi.fn();
|
||||
const { findByTestId, getByTestId, queryByTestId } = renderModal({
|
||||
hubSkillsEnabled: true,
|
||||
skillId: 'tone',
|
||||
skill: { name: 'Support tone', description: 'Use for replies', instructions: 'Be kind' },
|
||||
outdated: true,
|
||||
onUseLatestVersion,
|
||||
onRemove: vi.fn(),
|
||||
});
|
||||
|
||||
expect(await findByTestId('agent-skill-footer-hint')).toHaveTextContent(
|
||||
'agents.builder.skills.hint.outdated',
|
||||
);
|
||||
expect(getByTestId('agent-skill-description-text')).toHaveTextContent('Use for replies');
|
||||
expect(queryByTestId('agent-modal-title-input')).not.toBeInTheDocument();
|
||||
expect(queryByTestId('agent-skill-create-save')).not.toBeInTheDocument();
|
||||
expect(getByTestId('agent-skill-remove')).toBeInTheDocument();
|
||||
|
||||
await fireEvent.click(getByTestId('agent-skill-use-latest'));
|
||||
|
||||
expect(uiStore.closeModal).toHaveBeenCalledWith(MODAL_NAME);
|
||||
expect(onUseLatestVersion).toHaveBeenCalledWith('tone');
|
||||
});
|
||||
|
||||
it('keeps a skill that the user can edit editable', async () => {
|
||||
getHubSkill.mockResolvedValue(
|
||||
makeDetail({ id: 'tone', name: 'Support tone', scope: 'project', canEdit: true }),
|
||||
);
|
||||
const { findByTestId, queryByTestId } = renderModal({
|
||||
hubSkillsEnabled: true,
|
||||
skillId: 'tone',
|
||||
skill: { name: 'Support tone', description: 'Use for replies', instructions: 'Be kind' },
|
||||
});
|
||||
|
||||
await waitFor(() => expect(getHubSkill).toHaveBeenCalled());
|
||||
expect(await findByTestId('agent-modal-title-input')).toBeInTheDocument();
|
||||
expect(queryByTestId('agent-skill-footer-hint')).not.toBeInTheDocument();
|
||||
expect(queryByTestId('agent-skill-description-input')).toBeInTheDocument();
|
||||
expect(queryByTestId('agent-skill-create-save')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function makeListItem(
|
||||
overrides: Pick<SkillListItem, 'id' | 'name' | 'scope'> & Partial<SkillListItem>,
|
||||
): SkillListItem {
|
||||
return {
|
||||
description: `${overrides.name} description`,
|
||||
projectId: overrides.scope === 'project' ? 'p1' : null,
|
||||
projectName: overrides.scope === 'project' ? 'Marketing' : null,
|
||||
agentId: null,
|
||||
agentName: null,
|
||||
source: 'ui',
|
||||
latestVersion: 1,
|
||||
usedByAgents: 0,
|
||||
canEdit: true,
|
||||
canDelete: true,
|
||||
createdAt: '2026-10-01T00:00:00.000Z',
|
||||
updatedAt: '2026-10-01T00:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeDetail(
|
||||
overrides: Pick<SkillListItem, 'id' | 'name' | 'scope'> & Partial<SkillListItem>,
|
||||
): SkillDetail {
|
||||
const item = makeListItem(overrides);
|
||||
return {
|
||||
...item,
|
||||
skill: { name: item.name, description: item.description, instructions: 'Instructions' },
|
||||
skillHash: 'hash',
|
||||
usedBy: { drafts: [], pins: [], hiddenAgents: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
function getTitleInput(container: Element): HTMLInputElement {
|
||||
const input = container.querySelector<HTMLInputElement>(
|
||||
'[data-testid="agent-modal-title-input"]',
|
||||
|
|
|
|||
|
|
@ -115,6 +115,61 @@ describe('AgentSkillViewer', () => {
|
|||
expect(wrapper.emitted('select:path')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('shows a read-only skill as text, without form fields', () => {
|
||||
const wrapper = mountViewer({
|
||||
readOnly: true,
|
||||
skill: {
|
||||
name: 'Triage',
|
||||
description: 'Use for triage',
|
||||
instructions: 'Check the SLA first.',
|
||||
references: [{ path: 'references/labels.md', content: 'Use needs-repro.' }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.find('[data-testid="agent-skill-description-text"]').text()).toBe(
|
||||
'Use for triage',
|
||||
);
|
||||
expect(wrapper.find('[data-testid="agent-skill-instructions-text"]').text()).toContain(
|
||||
'Check the SLA first.',
|
||||
);
|
||||
expect(wrapper.find('[data-testid="agent-skill-description-input"]').exists()).toBe(false);
|
||||
expect(wrapper.find('[data-testid="agent-skill-instructions-editor"]').exists()).toBe(false);
|
||||
expect(wrapper.text()).not.toContain('characters');
|
||||
});
|
||||
|
||||
it('shows the task lists of a read-only skill as disabled checkboxes', () => {
|
||||
const wrapper = mountViewer({
|
||||
readOnly: true,
|
||||
skill: {
|
||||
name: 'Triage',
|
||||
description: 'Use for triage',
|
||||
instructions: '- [ ] Check the SLA',
|
||||
},
|
||||
});
|
||||
|
||||
const checkbox = wrapper.find('[data-testid="agent-skill-instructions-text"] input');
|
||||
expect(checkbox.attributes('type')).toBe('checkbox');
|
||||
expect(checkbox.attributes('disabled')).toBeDefined();
|
||||
});
|
||||
|
||||
it('shows a read-only reference as text', () => {
|
||||
const wrapper = mountViewer({
|
||||
readOnly: true,
|
||||
selectedPath: 'references/labels.md',
|
||||
skill: {
|
||||
name: 'Triage',
|
||||
description: 'Use for triage',
|
||||
instructions: 'Check the SLA first.',
|
||||
references: [{ path: 'references/labels.md', content: 'Use needs-repro.' }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.find('[data-testid="agent-skill-reference-text"]').text()).toContain(
|
||||
'Use needs-repro.',
|
||||
);
|
||||
expect(wrapper.find('[data-testid="agent-skill-reference-name-input"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('shows the instructions character count, not their byte size', () => {
|
||||
const wrapper = mountViewer({
|
||||
skill: {
|
||||
|
|
|
|||
|
|
@ -10,9 +10,6 @@ vi.mock('@n8n/i18n', () => ({
|
|||
useI18n: () => ({ baseText: (key: string) => key }),
|
||||
}));
|
||||
|
||||
const showMessage = vi.fn();
|
||||
vi.mock('@n8n/composables/useToast', () => ({ useToast: () => ({ showMessage }) }));
|
||||
|
||||
const skill = { name: 'Skill', description: 'A skill', instructions: 'Do it.' };
|
||||
|
||||
describe('AgentSkillsSection', () => {
|
||||
|
|
@ -35,7 +32,7 @@ describe('AgentSkillsSection', () => {
|
|||
expect(current.find('[data-testid="agent-chip-warning-icon"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('does not open an outdated skill and tells the user to add it again', async () => {
|
||||
it('opens an outdated skill like any other skill', async () => {
|
||||
const wrapper = mount(AgentSkillsSection, {
|
||||
props: {
|
||||
skills: [
|
||||
|
|
@ -50,11 +47,6 @@ describe('AgentSkillsSection', () => {
|
|||
await outdated.trigger('click');
|
||||
await current.trigger('click');
|
||||
|
||||
expect(wrapper.emitted('open-skill')).toEqual([['current']]);
|
||||
expect(showMessage).toHaveBeenCalledWith({
|
||||
type: 'warning',
|
||||
title: 'agents.builder.skills.outdated.title',
|
||||
message: 'agents.builder.skills.outdated.warning',
|
||||
});
|
||||
expect(wrapper.emitted('open-skill')).toEqual([['outdated'], ['current']]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -23,7 +23,12 @@ import {
|
|||
listN8nChatAgents,
|
||||
listN8nChatThreads,
|
||||
duplicateAgent,
|
||||
listAttachableSkills,
|
||||
createAgentSkill,
|
||||
getHubSkill,
|
||||
updateAgentSkill,
|
||||
} from '../composables/useAgentApi';
|
||||
import type { SkillListItem } from '@n8n/api-types';
|
||||
import type { AgentResource, AgentJsonConfig } from '../types';
|
||||
|
||||
vi.mock('@n8n/rest-api-client', () => ({
|
||||
|
|
@ -39,6 +44,79 @@ describe('useAgentApi', () => {
|
|||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('listAttachableSkills', () => {
|
||||
it('lists the attachable skills of the agent in one call, by target and then by name', async () => {
|
||||
const skill = (name: string, scope: SkillListItem['scope'], agentId: string | null = null) =>
|
||||
({ id: name, name, scope, agentId }) as SkillListItem;
|
||||
vi.mocked(makeRestApiRequest).mockResolvedValueOnce({
|
||||
count: 5,
|
||||
data: [
|
||||
skill('Tone', 'instance'),
|
||||
skill('Notes', 'project', 'agent-1'),
|
||||
skill('SQL', 'project'),
|
||||
skill('Brand', 'instance'),
|
||||
skill('Billing', 'project'),
|
||||
],
|
||||
});
|
||||
|
||||
const result = await listAttachableSkills(restApiContext, 'agent-1');
|
||||
|
||||
expect(makeRestApiRequest).toHaveBeenCalledWith(restApiContext, 'GET', '/skills', {
|
||||
attachableTo: 'agent-1',
|
||||
});
|
||||
expect(result.map(({ name }) => name)).toEqual(['Billing', 'SQL', 'Notes', 'Brand', 'Tone']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hub skill requests', () => {
|
||||
const skill = { name: 'Tone', description: 'Use for replies', instructions: 'Be kind' };
|
||||
|
||||
it('creates an agent skill with its target, and without one when none is given', async () => {
|
||||
await createAgentSkill(restApiContext, 'project-1', 'agent-1', skill, 'agent');
|
||||
await createAgentSkill(restApiContext, 'project-1', 'agent-1', skill);
|
||||
|
||||
expect(makeRestApiRequest).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
restApiContext,
|
||||
'POST',
|
||||
'/projects/project-1/agents/v2/agent-1/skills',
|
||||
{ ...skill, availableTo: 'agent' },
|
||||
);
|
||||
expect(makeRestApiRequest).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
restApiContext,
|
||||
'POST',
|
||||
'/projects/project-1/agents/v2/agent-1/skills',
|
||||
skill,
|
||||
);
|
||||
});
|
||||
|
||||
it('saves the skill text and its target in one request', async () => {
|
||||
await updateAgentSkill(
|
||||
restApiContext,
|
||||
'project-1',
|
||||
'agent-1',
|
||||
'skill-1',
|
||||
skill,
|
||||
'hash-1',
|
||||
'instance',
|
||||
);
|
||||
|
||||
expect(makeRestApiRequest).toHaveBeenCalledWith(
|
||||
restApiContext,
|
||||
'PATCH',
|
||||
'/projects/project-1/agents/v2/agent-1/skills/skill-1',
|
||||
{ ...skill, baseSkillHash: 'hash-1', availableTo: 'instance' },
|
||||
);
|
||||
});
|
||||
|
||||
it('gets one hub skill', async () => {
|
||||
await getHubSkill(restApiContext, 'skill-1');
|
||||
|
||||
expect(makeRestApiRequest).toHaveBeenCalledWith(restApiContext, 'GET', '/skills/skill-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('listAgentsPage', () => {
|
||||
it('requests agents using the raw paginated response contract', async () => {
|
||||
const response = {
|
||||
|
|
|
|||
|
|
@ -5,9 +5,11 @@ import { createTestingPinia } from '@pinia/testing';
|
|||
import { setActivePinia } from 'pinia';
|
||||
|
||||
import { useUIStore } from '@/app/stores/ui.store';
|
||||
import { useSettingsStore } from '@n8n/stores/settings.store';
|
||||
import { AGENT_TOOLS_MODAL_KEY, AGENT_SKILL_MODAL_KEY } from '../constants';
|
||||
import { useAgentCapabilitiesActions } from '../composables/useAgentCapabilitiesActions';
|
||||
import { createAgentSkill } from '../composables/useAgentApi';
|
||||
import { createAgentSkill, getHubSkill } from '../composables/useAgentApi';
|
||||
import type { SkillDetail, SkillListItem } from '@n8n/api-types';
|
||||
import type {
|
||||
AgentJsonConfig,
|
||||
AgentJsonMcpServerConfig,
|
||||
|
|
@ -34,6 +36,14 @@ vi.mock('@n8n/composables/useToast', () => ({
|
|||
|
||||
vi.mock('../composables/useAgentApi', () => ({
|
||||
createAgentSkill: vi.fn(),
|
||||
getHubSkill: vi.fn(),
|
||||
}));
|
||||
|
||||
const { hasScopeSpy } = vi.hoisted(() => ({
|
||||
hasScopeSpy: vi.fn(() => false),
|
||||
}));
|
||||
vi.mock('@n8n/stores/rbac.store', () => ({
|
||||
useRBACStore: () => ({ hasScope: hasScopeSpy }),
|
||||
}));
|
||||
|
||||
function makeConfig(overrides: Partial<AgentJsonConfig> = {}): AgentJsonConfig {
|
||||
|
|
@ -51,6 +61,7 @@ function setup(
|
|||
overrides: {
|
||||
supportsToolApproval?: boolean;
|
||||
ensureAgentPersisted?: () => Promise<void>;
|
||||
flushConfigSave?: () => Promise<unknown>;
|
||||
beforeAgentMutation?: () => Promise<void>;
|
||||
refreshAgentAfterMutation?: (projectId: string, agentId: string) => Promise<boolean>;
|
||||
agent?: Ref<AgentResource | null>;
|
||||
|
|
@ -63,6 +74,7 @@ function setup(
|
|||
|
||||
const localConfig = ref<AgentJsonConfig | null>(makeConfig());
|
||||
const scheduleConfigUpdate = vi.fn();
|
||||
const scheduleSkillSave = vi.fn();
|
||||
const agentId = overrides.agentId ?? ref('inline:node-1');
|
||||
const projectId = overrides.projectId ?? ref('project-1');
|
||||
|
||||
|
|
@ -73,11 +85,19 @@ function setup(
|
|||
agentId: computed(() => agentId.value),
|
||||
connectedTriggers: ref([]),
|
||||
scheduleConfigUpdate,
|
||||
scheduleSkillSave: vi.fn(),
|
||||
scheduleSkillSave,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
return { uiStore, actions, scheduleConfigUpdate, localConfig, agentId, projectId };
|
||||
return {
|
||||
uiStore,
|
||||
actions,
|
||||
scheduleConfigUpdate,
|
||||
scheduleSkillSave,
|
||||
localConfig,
|
||||
agentId,
|
||||
projectId,
|
||||
};
|
||||
}
|
||||
|
||||
type ToolsModalData = {
|
||||
|
|
@ -150,8 +170,19 @@ describe('useAgentCapabilitiesActions — tools modal host seam', () => {
|
|||
|
||||
type SkillModalData = {
|
||||
skillId?: string;
|
||||
skill?: AgentSkill;
|
||||
existingSkillNames?: string[];
|
||||
onConfirm: (payload: { id?: string; skill: AgentSkill }) => void;
|
||||
hubSkillsEnabled?: boolean;
|
||||
outdated?: boolean;
|
||||
onUseLatestVersion?: (skillId: string) => void;
|
||||
getAttachedSkills?: () => Array<{ id: string; name: string }>;
|
||||
canCreateInstanceSkills?: boolean;
|
||||
onConfirm: (payload: {
|
||||
id?: string;
|
||||
skill: AgentSkill;
|
||||
availableTo?: 'project' | 'agent' | 'instance';
|
||||
}) => void;
|
||||
onAttach?: (skill: SkillListItem) => Promise<void>;
|
||||
onRemove?: (skillId: string) => void;
|
||||
};
|
||||
|
||||
|
|
@ -580,3 +611,378 @@ describe('useAgentCapabilitiesActions — localSkills host seam', () => {
|
|||
expect(actions.appliedSkills.value).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useAgentCapabilitiesActions — skills hub', () => {
|
||||
const supportTone: AgentSkill = {
|
||||
name: 'Support tone',
|
||||
description: 'Use for support replies',
|
||||
instructions: 'Be kind.',
|
||||
};
|
||||
const sqlHelper: AgentSkill = {
|
||||
name: 'SQL helper',
|
||||
description: 'Use for SQL questions',
|
||||
instructions: 'Write SQL.',
|
||||
};
|
||||
|
||||
function makeDetail(id: string, skill: AgentSkill): SkillDetail {
|
||||
return {
|
||||
id,
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
scope: 'instance',
|
||||
projectId: null,
|
||||
projectName: null,
|
||||
agentId: null,
|
||||
agentName: null,
|
||||
source: 'ui',
|
||||
latestVersion: 1,
|
||||
usedByAgents: 0,
|
||||
canEdit: false,
|
||||
canDelete: false,
|
||||
createdAt: '2026-10-01T00:00:00.000Z',
|
||||
updatedAt: '2026-10-01T00:00:00.000Z',
|
||||
skill,
|
||||
skillHash: `hash-${id}`,
|
||||
usedBy: { drafts: [], pins: [], hiddenAgents: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
function setupHub({
|
||||
skillsHubEnabled = true,
|
||||
flushConfigSave,
|
||||
}: { skillsHubEnabled?: boolean; flushConfigSave?: () => Promise<unknown> } = {}) {
|
||||
const agent = ref<AgentResource | null>({
|
||||
id: 'agent-1',
|
||||
skills: { tone: supportTone },
|
||||
skillHashes: { tone: 'hash-tone' },
|
||||
} as unknown as AgentResource);
|
||||
const result = setup({ agent, agentId: ref('agent-1'), flushConfigSave });
|
||||
useSettingsStore().moduleSettings.agents = {
|
||||
enabled: true,
|
||||
modules: [],
|
||||
knowledgeBaseEnabled: false,
|
||||
proxyEnabled: false,
|
||||
skillsHubEnabled,
|
||||
};
|
||||
result.localConfig.value = makeConfig({ skills: [{ type: 'skill', id: 'tone' }] });
|
||||
result.actions.onOpenAddSkillModal();
|
||||
const modalData = result.uiStore.modalsById[AGENT_SKILL_MODAL_KEY]
|
||||
.data as unknown as SkillModalData;
|
||||
return { ...result, agent, modalData };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
hasScopeSpy.mockReturnValue(false);
|
||||
});
|
||||
|
||||
it('saves a new agent before it opens the picker', async () => {
|
||||
let releasePersist: () => void = () => {};
|
||||
const persisted = new Promise<void>((resolve) => {
|
||||
releasePersist = resolve;
|
||||
});
|
||||
const { uiStore, actions } = setup({ ensureAgentPersisted: async () => await persisted });
|
||||
useSettingsStore().moduleSettings.agents = {
|
||||
enabled: true,
|
||||
modules: [],
|
||||
knowledgeBaseEnabled: false,
|
||||
proxyEnabled: false,
|
||||
skillsHubEnabled: true,
|
||||
};
|
||||
|
||||
void actions.onOpenAddSkillModal();
|
||||
await flushPromises();
|
||||
expect(uiStore.modalsById[AGENT_SKILL_MODAL_KEY]?.open).toBeFalsy();
|
||||
|
||||
releasePersist();
|
||||
await flushPromises();
|
||||
expect(uiStore.modalsById[AGENT_SKILL_MODAL_KEY].open).toBe(true);
|
||||
});
|
||||
|
||||
it('reports a failed agent save and keeps the picker closed', async () => {
|
||||
const error = new Error('create failed');
|
||||
const { uiStore, actions } = setup({
|
||||
ensureAgentPersisted: async () => await Promise.reject(error),
|
||||
});
|
||||
useSettingsStore().moduleSettings.agents = {
|
||||
enabled: true,
|
||||
modules: [],
|
||||
knowledgeBaseEnabled: false,
|
||||
proxyEnabled: false,
|
||||
skillsHubEnabled: true,
|
||||
};
|
||||
|
||||
await actions.onOpenAddSkillModal();
|
||||
|
||||
expect(uiStore.modalsById[AGENT_SKILL_MODAL_KEY]?.open).toBeFalsy();
|
||||
expect(showErrorSpy).toHaveBeenCalledWith(error, 'agents.builder.skills.picker.loadError');
|
||||
});
|
||||
|
||||
it('opens the add modal with the picker data', () => {
|
||||
hasScopeSpy.mockReturnValue(true);
|
||||
const { modalData } = setupHub();
|
||||
|
||||
expect(modalData.hubSkillsEnabled).toBe(true);
|
||||
expect(modalData.getAttachedSkills?.()).toEqual([{ id: 'tone', name: supportTone.name }]);
|
||||
expect(modalData.canCreateInstanceSkills).toBe(true);
|
||||
expect(hasScopeSpy).toHaveBeenCalledWith('skill:create');
|
||||
});
|
||||
|
||||
it('keeps the picker off and creates the skill on the agent when the skills hub is off', async () => {
|
||||
vi.mocked(createAgentSkill).mockResolvedValue({
|
||||
id: 'sql',
|
||||
skill: sqlHelper,
|
||||
skillHash: 'hash-sql',
|
||||
versionId: 'v2',
|
||||
} as Awaited<ReturnType<typeof createAgentSkill>>);
|
||||
const { modalData, actions, uiStore } = setupHub({ skillsHubEnabled: false });
|
||||
|
||||
expect(modalData.hubSkillsEnabled).toBe(false);
|
||||
modalData.onConfirm({ skill: sqlHelper, availableTo: 'project' });
|
||||
await flushPromises();
|
||||
expect(createAgentSkill).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'project-1',
|
||||
'agent-1',
|
||||
sqlHelper,
|
||||
undefined,
|
||||
);
|
||||
expect(getHubSkill).not.toHaveBeenCalled();
|
||||
|
||||
actions.onOpenSkillFromList('tone');
|
||||
const editData = uiStore.modalsById[AGENT_SKILL_MODAL_KEY].data as unknown as SkillModalData;
|
||||
expect(editData.hubSkillsEnabled).toBe(false);
|
||||
expect(editData.outdated).toBe(false);
|
||||
});
|
||||
|
||||
it('attaches a hub skill as a ref through the config save, without a copy', async () => {
|
||||
vi.mocked(getHubSkill).mockResolvedValue(makeDetail('sql', sqlHelper));
|
||||
const { modalData, scheduleConfigUpdate, agent } = setupHub();
|
||||
|
||||
await modalData.onAttach?.({ id: 'sql', name: 'SQL helper' } as SkillListItem);
|
||||
await flushPromises();
|
||||
|
||||
expect(getHubSkill).toHaveBeenCalledWith(expect.anything(), 'sql');
|
||||
expect(scheduleConfigUpdate).toHaveBeenCalledWith({
|
||||
skills: [
|
||||
{ type: 'skill', id: 'tone' },
|
||||
{ type: 'skill', id: 'sql' },
|
||||
],
|
||||
});
|
||||
expect(agent.value?.skills.sql).toEqual(sqlHelper);
|
||||
expect(agent.value?.skillHashes?.sql).toBe('hash-sql');
|
||||
expect(createAgentSkill).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('undoes an attach when the config save fails', async () => {
|
||||
vi.mocked(getHubSkill).mockResolvedValue(makeDetail('sql', sqlHelper));
|
||||
const { modalData, scheduleConfigUpdate, agent } = setupHub({
|
||||
flushConfigSave: async () => await Promise.reject(new Error('refused')),
|
||||
});
|
||||
|
||||
await modalData.onAttach?.({ id: 'sql', name: 'SQL helper' } as SkillListItem);
|
||||
await flushPromises();
|
||||
|
||||
expect(scheduleConfigUpdate).toHaveBeenLastCalledWith({
|
||||
skills: [{ type: 'skill', id: 'tone' }],
|
||||
});
|
||||
expect(agent.value?.skills.sql).toBeUndefined();
|
||||
expect(agent.value?.skillHashes?.sql).toBeUndefined();
|
||||
expect(agent.value?.skills.tone).toEqual(supportTone);
|
||||
});
|
||||
|
||||
it('keeps an attach when the config save succeeds', async () => {
|
||||
vi.mocked(getHubSkill).mockResolvedValue(makeDetail('sql', sqlHelper));
|
||||
const { modalData, scheduleConfigUpdate, agent } = setupHub({
|
||||
flushConfigSave: async () => await Promise.resolve(undefined),
|
||||
});
|
||||
|
||||
await modalData.onAttach?.({ id: 'sql', name: 'SQL helper' } as SkillListItem);
|
||||
await flushPromises();
|
||||
|
||||
expect(scheduleConfigUpdate).toHaveBeenCalledTimes(1);
|
||||
expect(agent.value?.skills.sql).toEqual(sqlHelper);
|
||||
});
|
||||
|
||||
it('refuses to attach a skill whose name is hard to tell apart from an attached one', async () => {
|
||||
vi.mocked(getHubSkill).mockResolvedValue(
|
||||
makeDetail('tone-2', { ...supportTone, name: 'support_tone' }),
|
||||
);
|
||||
const { modalData, scheduleConfigUpdate } = setupHub();
|
||||
|
||||
await modalData.onAttach?.({ id: 'tone-2', name: 'support_tone' } as SkillListItem);
|
||||
await flushPromises();
|
||||
|
||||
expect(scheduleConfigUpdate).not.toHaveBeenCalled();
|
||||
expect(showMessageSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ title: 'agents.builder.skills.duplicateName.error' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('reports an attach failure and leaves the config alone', async () => {
|
||||
vi.mocked(getHubSkill).mockRejectedValue(new Error('Not found'));
|
||||
const { modalData, scheduleConfigUpdate } = setupHub();
|
||||
|
||||
await modalData.onAttach?.({ id: 'sql', name: 'SQL helper' } as SkillListItem);
|
||||
await flushPromises();
|
||||
|
||||
expect(showErrorSpy).toHaveBeenCalledWith(
|
||||
expect.any(Error),
|
||||
'agents.builder.skills.attach.error',
|
||||
);
|
||||
expect(scheduleConfigUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(['project', 'agent', 'instance'] as const)(
|
||||
'creates a skill for the %s through the agent endpoint',
|
||||
async (availableTo) => {
|
||||
vi.mocked(createAgentSkill).mockResolvedValue({
|
||||
id: 'sql',
|
||||
skill: sqlHelper,
|
||||
skillHash: 'hash-sql',
|
||||
versionId: 'v2',
|
||||
});
|
||||
const { modalData } = setupHub();
|
||||
|
||||
modalData.onConfirm({ skill: sqlHelper, availableTo });
|
||||
await flushPromises();
|
||||
|
||||
expect(createAgentSkill).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'project-1',
|
||||
'agent-1',
|
||||
sqlHelper,
|
||||
availableTo,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('detaches a skill by dropping its ref only', () => {
|
||||
const { actions, scheduleConfigUpdate } = setupHub();
|
||||
|
||||
actions.onRemoveSkill('tone');
|
||||
|
||||
expect(scheduleConfigUpdate).toHaveBeenCalledWith({ skills: [] });
|
||||
});
|
||||
|
||||
it('detaches a skill from the picker preview by dropping its ref only', () => {
|
||||
const { modalData, scheduleConfigUpdate } = setupHub();
|
||||
|
||||
modalData.onRemove?.('tone');
|
||||
|
||||
expect(scheduleConfigUpdate).toHaveBeenCalledWith({ skills: [] });
|
||||
});
|
||||
|
||||
it('marks a skill on an older version when the edit modal opens', () => {
|
||||
const { actions, uiStore, agent } = setupHub();
|
||||
agent.value = { ...agent.value, outdatedSkillIds: ['tone'] } as unknown as AgentResource;
|
||||
|
||||
actions.onOpenSkillFromList('tone');
|
||||
|
||||
const modalData = uiStore.modalsById[AGENT_SKILL_MODAL_KEY].data as unknown as SkillModalData;
|
||||
expect(modalData.outdated).toBe(true);
|
||||
});
|
||||
|
||||
it('unpins the skill and saves before it opens the latest version in the editor', async () => {
|
||||
const latest: AgentSkill = { ...supportTone, instructions: 'Be kind and brief.' };
|
||||
vi.mocked(getHubSkill).mockResolvedValue({
|
||||
...makeDetail('tone', latest),
|
||||
skillHash: 'hash-latest',
|
||||
});
|
||||
const agent = ref<AgentResource | null>({
|
||||
id: 'agent-1',
|
||||
skills: { tone: supportTone },
|
||||
skillHashes: { tone: 'hash-tone' },
|
||||
outdatedSkillIds: ['tone'],
|
||||
} as unknown as AgentResource);
|
||||
const events: string[] = [];
|
||||
const beforeAgentMutation = vi.fn(async () => {
|
||||
events.push('flush');
|
||||
});
|
||||
const { actions, uiStore, localConfig, scheduleConfigUpdate } = setup({
|
||||
agent,
|
||||
agentId: ref('agent-1'),
|
||||
beforeAgentMutation,
|
||||
});
|
||||
scheduleConfigUpdate.mockImplementation(() => events.push('config'));
|
||||
localConfig.value = makeConfig({
|
||||
skills: [{ type: 'skill', id: 'tone', versionId: 'v1' }],
|
||||
});
|
||||
actions.onOpenSkillFromList('tone');
|
||||
const outdatedData = uiStore.modalsById[AGENT_SKILL_MODAL_KEY]
|
||||
.data as unknown as SkillModalData;
|
||||
|
||||
outdatedData.onUseLatestVersion?.('tone');
|
||||
await flushPromises();
|
||||
|
||||
expect(scheduleConfigUpdate).toHaveBeenCalledWith({ skills: [{ type: 'skill', id: 'tone' }] });
|
||||
expect(events).toEqual(['config', 'flush']);
|
||||
expect(agent.value?.skills.tone).toEqual(latest);
|
||||
expect(agent.value?.skillHashes?.tone).toBe('hash-latest');
|
||||
expect(agent.value?.outdatedSkillIds).toEqual([]);
|
||||
const editData = uiStore.modalsById[AGENT_SKILL_MODAL_KEY].data as unknown as SkillModalData;
|
||||
expect(editData.skill).toEqual(latest);
|
||||
expect(editData.outdated).toBe(false);
|
||||
});
|
||||
|
||||
function openEdit(options: { skillsHubEnabled?: boolean } = {}) {
|
||||
const result = setupHub(options);
|
||||
result.actions.onOpenSkillFromList('tone');
|
||||
const editData = result.uiStore.modalsById[AGENT_SKILL_MODAL_KEY]
|
||||
.data as unknown as SkillModalData;
|
||||
return { ...result, editData };
|
||||
}
|
||||
|
||||
it('saves the content and the target of an edited skill in one skill save', () => {
|
||||
const { editData, scheduleSkillSave } = openEdit();
|
||||
|
||||
editData.onConfirm({ id: 'tone', skill: supportTone, availableTo: 'agent' });
|
||||
|
||||
expect(scheduleSkillSave).toHaveBeenCalledWith({
|
||||
skillId: 'tone',
|
||||
skill: supportTone,
|
||||
availableTo: 'agent',
|
||||
});
|
||||
});
|
||||
|
||||
it('saves no target when the edit keeps it or the skills hub is off', () => {
|
||||
const kept = openEdit();
|
||||
kept.editData.onConfirm({ id: 'tone', skill: supportTone });
|
||||
const hubOff = openEdit({ skillsHubEnabled: false });
|
||||
hubOff.editData.onConfirm({ id: 'tone', skill: supportTone, availableTo: 'agent' });
|
||||
|
||||
expect(kept.scheduleSkillSave).toHaveBeenCalledWith({ skillId: 'tone', skill: supportTone });
|
||||
expect(hubOff.scheduleSkillSave).toHaveBeenCalledWith({ skillId: 'tone', skill: supportTone });
|
||||
});
|
||||
|
||||
it('tells the edit modal to check the edit permission', () => {
|
||||
const { actions, uiStore } = setupHub();
|
||||
|
||||
actions.onOpenSkillFromList('tone');
|
||||
|
||||
const modalData = uiStore.modalsById[AGENT_SKILL_MODAL_KEY].data as unknown as SkillModalData;
|
||||
expect(modalData.skillId).toBe('tone');
|
||||
expect(modalData.hubSkillsEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it('never enables the hub for a host that keeps its own skill bodies', () => {
|
||||
const localConfig = ref<AgentJsonConfig | null>(makeConfig());
|
||||
setActivePinia(createTestingPinia({ stubActions: false }));
|
||||
const uiStore = useUIStore();
|
||||
const actions = useAgentCapabilitiesActions({
|
||||
localConfig,
|
||||
agent: ref<AgentResource | null>(null),
|
||||
projectId: computed(() => 'project-1'),
|
||||
agentId: computed(() => 'inline:node-1'),
|
||||
connectedTriggers: ref([]),
|
||||
scheduleConfigUpdate: vi.fn(),
|
||||
scheduleSkillSave: vi.fn(),
|
||||
localSkills: { bodies: computed(() => ({})), createSkill: vi.fn(), updateSkill: vi.fn() },
|
||||
});
|
||||
|
||||
actions.onOpenAddSkillModal();
|
||||
|
||||
const modalData = uiStore.modalsById[AGENT_SKILL_MODAL_KEY].data as unknown as SkillModalData;
|
||||
expect(modalData.hubSkillsEnabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -184,7 +184,7 @@ text and `3xs` spacing. Keep warning and failure indicators distinct.
|
|||
### Multi-step contract
|
||||
|
||||
Use the multi-step component for Channels, Sub-agents, Tools, MCP servers,
|
||||
Workflows, and Vector stores.
|
||||
Workflows, Vector stores, and Skills.
|
||||
|
||||
For tools, keep selection and configuration separate. The picker owns search,
|
||||
categories, rows, and connection entry points. After selection, route every
|
||||
|
|
@ -223,6 +223,40 @@ a direct pill edit. Save, Cancel, and Close exit the complete flow.
|
|||
| Channel platform setup | A platform-owned setup action can stay in the inline modal content. The Agent shell still owns navigation and dismissal. |
|
||||
| Memory | Do not migrate Memory into this pattern in this change. |
|
||||
|
||||
When the skills hub is on, `Add skill` starts with the `Skills` picker. The
|
||||
picker shows only skills that the Agent can use: skills of its project, skills
|
||||
of this Agent only, and global skills. Group them in the tabs All, the project
|
||||
name, This agent, and Global. Do not
|
||||
show a scope label or an icon in a row. Each row has a switch with the label
|
||||
`Attached` or `Detached`. The switch saves at once and keeps the picker open.
|
||||
This is an exception to the explicit row action. Disable a skill whose name is
|
||||
hard to tell apart from an attached skill, and give the reason in its tooltip.
|
||||
A row click opens a read-only preview with Back, Cancel, and `Add skill`. The
|
||||
preview of an attached skill shows `Detach` instead of `Add skill`. Both
|
||||
actions return to the picker. Do not edit a skill from the picker.
|
||||
`Create skill` shows on every tab. It opens the upload screen, and Back returns
|
||||
to the picker. The first field of the skill form is `Where can this skill be
|
||||
added?`: `This project`, `This agent only`, or `Global`. Each choice has an
|
||||
icon. The field starts from the tab: the project tab starts at `This project`,
|
||||
the Global tab at `Global`, and the other tabs at `This agent only`. Only a
|
||||
user who can create global skills sees `Global`. An existing skill that the
|
||||
user can edit shows the same field. Disable it, with a tooltip that gives the
|
||||
reason, when another Agent uses the skill. `Save skill` stays disabled until
|
||||
the user changes the skill or its target. Do not show the field when the
|
||||
skills hub is off. Then `Add skill` creates the skill on the Agent, without the picker.
|
||||
|
||||
Show a read-only skill as a document, not as a disabled form. Show each label
|
||||
in muted text without the required mark, the description as text, and the
|
||||
instructions and references as rendered markdown. Put a muted hint before Cancel that tells the
|
||||
user how to edit the skill, or whom to ask. A skill that the user can use but
|
||||
not edit opens this way. Hide Save. Keep `Detach` for an attached skill, and
|
||||
`Add skill` for a preview of a skill that is not attached.
|
||||
|
||||
A skill that the draft pins to an older version also opens read-only, with
|
||||
the hint that the agent uses an older version. Its primary action is
|
||||
`Use latest version`. The action removes the pin, saves the draft, and opens
|
||||
the latest version in the editor.
|
||||
|
||||
New skills start with an upload screen. Make `Upload folder` the primary
|
||||
action. Support a single `SKILL.md` file and folder drops. Put `Add manually`
|
||||
below the drop zone, inline with the prompt. Keep the same font and underline.
|
||||
|
|
|
|||
|
|
@ -140,6 +140,7 @@ const emit = defineEmits<{
|
|||
}
|
||||
|
||||
.alertIcon {
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,9 +12,11 @@ const props = withDefaults(
|
|||
skill: AgentSkill;
|
||||
selectedPath: string;
|
||||
addReferenceDisabled?: boolean;
|
||||
readOnly?: boolean;
|
||||
}>(),
|
||||
{
|
||||
addReferenceDisabled: false,
|
||||
readOnly: false,
|
||||
},
|
||||
);
|
||||
|
||||
|
|
@ -59,6 +61,7 @@ function testIdForPath(path: string) {
|
|||
{{ i18n.baseText('agents.builder.skills.references.title') }}
|
||||
</N8nText>
|
||||
<N8nButton
|
||||
v-if="!props.readOnly"
|
||||
:class="$style.addReference"
|
||||
variant="ghost"
|
||||
size="xsmall"
|
||||
|
|
@ -95,6 +98,7 @@ function testIdForPath(path: string) {
|
|||
<N8nText size="small" :class="$style.path">{{ reference.path }}</N8nText>
|
||||
</button>
|
||||
<N8nButton
|
||||
v-if="!props.readOnly"
|
||||
:class="$style.removeReference"
|
||||
variant="ghost"
|
||||
size="xsmall"
|
||||
|
|
|
|||
|
|
@ -1,20 +1,44 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import cloneDeep from 'lodash/cloneDeep';
|
||||
import isEqual from 'lodash/isEqual';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import {
|
||||
AGENT_SKILL_INSTRUCTIONS_MAX_LENGTH,
|
||||
AGENT_SKILL_REFERENCE_MAX_COUNT,
|
||||
skillNameKey,
|
||||
type AgentSkillAvailability,
|
||||
type SkillDetail,
|
||||
type SkillListItem,
|
||||
} from '@n8n/api-types';
|
||||
import { N8nButton, N8nCallout, N8nIcon } from '@n8n/design-system';
|
||||
import { useToast } from '@n8n/composables/useToast';
|
||||
import {
|
||||
N8nButton,
|
||||
N8nCallout,
|
||||
N8nIcon,
|
||||
N8nInputLabel,
|
||||
N8nOption,
|
||||
N8nSelect,
|
||||
N8nText,
|
||||
N8nTooltip,
|
||||
} from '@n8n/design-system';
|
||||
import { useI18n, type BaseTextKey } from '@n8n/i18n';
|
||||
import { useRootStore } from '@n8n/stores/useRootStore';
|
||||
|
||||
import { useUIStore } from '@/app/stores/ui.store';
|
||||
import ToolsConnectionModal from '@/features/shared/toolsConnection/ToolsConnectionModal.vue';
|
||||
import type {
|
||||
SkillConnectionItem,
|
||||
ToolCategoryKey,
|
||||
ToolConnectionItem,
|
||||
} from '@/features/shared/toolsConnection/types';
|
||||
import { getHubSkill, listAttachableSkills } from '../composables/useAgentApi';
|
||||
import { useAgentTelemetry } from '../composables/useAgentTelemetry';
|
||||
import type { AgentSkill } from '../types';
|
||||
import { normalizeAgentSkillForSave } from '../utils/agentSkill';
|
||||
import AgentSkillFileNav from './AgentSkillFileNav.vue';
|
||||
import AgentSkillUpload from './AgentSkillUpload.vue';
|
||||
import AgentSkillViewer from './AgentSkillViewer.vue';
|
||||
import AgentModal from './modals/AgentModal.vue';
|
||||
import AgentModalMultiStep from './modals/AgentModalMultiStep.vue';
|
||||
|
||||
const SKILL_FILE = 'SKILL.md';
|
||||
|
||||
|
|
@ -28,7 +52,18 @@ export type AgentSkillModalData = {
|
|||
* the modal is still open — skill names must be unique per agent.
|
||||
*/
|
||||
existingSkillNames?: string[];
|
||||
onConfirm: (payload: { id?: string; skill: AgentSkill }) => void;
|
||||
hubSkillsEnabled?: boolean;
|
||||
getAttachedSkills?: () => Array<{ id: string; name: string }>;
|
||||
projectLabel?: string;
|
||||
canCreateInstanceSkills?: boolean;
|
||||
onConfirm: (payload: {
|
||||
id?: string;
|
||||
skill: AgentSkill;
|
||||
availableTo?: AgentSkillAvailability;
|
||||
}) => void;
|
||||
outdated?: boolean;
|
||||
onUseLatestVersion?: (id: string) => void;
|
||||
onAttach?: (skill: SkillListItem) => Promise<void>;
|
||||
onRemove?: (id: string) => void;
|
||||
};
|
||||
|
||||
|
|
@ -39,38 +74,162 @@ const props = defineProps<{
|
|||
|
||||
const i18n = useI18n();
|
||||
const uiStore = useUIStore();
|
||||
const rootStore = useRootStore();
|
||||
const { showError } = useToast();
|
||||
const agentTelemetry = useAgentTelemetry();
|
||||
const modalOpen = computed(() => uiStore.modalsById[props.modalName]?.open === true);
|
||||
|
||||
const existingNameKeys = computed(() => {
|
||||
const names = props.data.getAttachedSkills
|
||||
? props.data.getAttachedSkills().map(({ name }) => name)
|
||||
: (props.data.existingSkillNames ?? []);
|
||||
return new Set(names.map(skillNameKey));
|
||||
});
|
||||
|
||||
function getDefaultSkillName(): string {
|
||||
const baseName = i18n.baseText('agents.builder.skills.defaultName' as BaseTextKey);
|
||||
const existingNames = new Set(
|
||||
(props.data.existingSkillNames ?? []).map((name) => name.trim().toLowerCase()),
|
||||
);
|
||||
if (!existingNames.has(baseName.toLowerCase())) return baseName;
|
||||
if (!existingNameKeys.value.has(skillNameKey(baseName))) return baseName;
|
||||
|
||||
let suffix = 2;
|
||||
while (existingNames.has(`${baseName} ${suffix}`.toLowerCase())) suffix += 1;
|
||||
while (existingNameKeys.value.has(skillNameKey(`${baseName} ${suffix}`))) suffix += 1;
|
||||
return `${baseName} ${suffix}`;
|
||||
}
|
||||
|
||||
const skill = ref<AgentSkill>(
|
||||
normalizeSkill({
|
||||
name: props.data.skill?.name ?? getDefaultSkillName(),
|
||||
description: props.data.skill?.description ?? '',
|
||||
instructions: props.data.skill?.instructions ?? '',
|
||||
...(props.data.skill?.references ? { references: props.data.skill.references } : {}),
|
||||
}),
|
||||
);
|
||||
const initialSkill = normalizeSkill({
|
||||
name: props.data.skill?.name ?? getDefaultSkillName(),
|
||||
description: props.data.skill?.description ?? '',
|
||||
instructions: props.data.skill?.instructions ?? '',
|
||||
...(props.data.skill?.references ? { references: props.data.skill.references } : {}),
|
||||
});
|
||||
const skill = ref<AgentSkill>(cloneDeep(initialSkill));
|
||||
const submitted = ref(false);
|
||||
const formIsValid = ref(false);
|
||||
const selectedPath = ref(SKILL_FILE);
|
||||
const step = ref<'upload' | 'manual' | 'edit'>(
|
||||
props.data.skill || props.data.skillId ? 'edit' : 'upload',
|
||||
const isEditing = computed(() => !!props.data.skillId);
|
||||
const hasPicker = !props.data.skillId && !!props.data.hubSkillsEnabled && !!props.data.onAttach;
|
||||
const step = ref<'select' | 'preview' | 'upload' | 'manual' | 'edit'>(
|
||||
props.data.skill || props.data.skillId ? 'edit' : hasPicker ? 'select' : 'upload',
|
||||
);
|
||||
const isImporting = ref(false);
|
||||
const availableTo = ref<AgentSkillAvailability>('project');
|
||||
|
||||
const librarySkills = ref<SkillListItem[]>([]);
|
||||
const libraryLoading = ref(false);
|
||||
const attachedSkills = computed(() => props.data.getAttachedSkills?.() ?? []);
|
||||
const attachedSkillIds = computed(() => new Set(attachedSkills.value.map(({ id }) => id)));
|
||||
const attachingSkillIds = ref(new Set<string>());
|
||||
const pickerItems = computed<SkillConnectionItem[]>(() =>
|
||||
librarySkills.value.map((librarySkill) => {
|
||||
const attached = attachedSkillIds.value.has(librarySkill.id);
|
||||
const attaching = attachingSkillIds.value.has(librarySkill.id);
|
||||
const similarNameReason =
|
||||
attached || attaching ? undefined : getSimilarNameReason(librarySkill.name);
|
||||
return {
|
||||
id: `skill:${librarySkill.id}`,
|
||||
kind: 'skill',
|
||||
skillId: librarySkill.id,
|
||||
title: librarySkill.name,
|
||||
description: librarySkill.description,
|
||||
category: getPickerCategory(librarySkill),
|
||||
status: 'none',
|
||||
attached: attached || attaching,
|
||||
attaching,
|
||||
disabled: similarNameReason !== undefined,
|
||||
...(similarNameReason ? { disabledReason: similarNameReason } : {}),
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const previewSkill = ref<SkillDetail | null>(null);
|
||||
const previewPath = ref(SKILL_FILE);
|
||||
const previewLoading = ref(false);
|
||||
const previewIsAdded = computed(
|
||||
() => !!previewSkill.value && attachedSkillIds.value.has(previewSkill.value.id),
|
||||
);
|
||||
const showRemove = computed(() => {
|
||||
if (!props.data.onRemove) return false;
|
||||
return step.value === 'preview' ? previewIsAdded.value : isEditing.value;
|
||||
});
|
||||
const footerPrimaryAction = computed<'add' | 'save' | 'useLatest' | undefined>(() => {
|
||||
if (step.value === 'preview') return previewIsAdded.value ? undefined : 'add';
|
||||
if (isOutdated.value) return props.data.onUseLatestVersion ? 'useLatest' : undefined;
|
||||
return isReadOnly.value ? undefined : 'save';
|
||||
});
|
||||
const modalTitle = computed(() => {
|
||||
if (step.value === 'select') return i18n.baseText('agents.builder.skills.title');
|
||||
if (step.value === 'upload') return i18n.baseText('agents.builder.skills.add');
|
||||
if (step.value === 'preview') return previewSkill.value?.name ?? '';
|
||||
return skill.value.name;
|
||||
});
|
||||
|
||||
const skillAccess = ref<SkillDetail | null>(null);
|
||||
const skillAccessLoading = ref(false);
|
||||
const skillAccessFailed = ref(false);
|
||||
const isOutdated = computed(() => isEditing.value && !!props.data.outdated);
|
||||
const isReadOnly = computed(
|
||||
() =>
|
||||
skillAccessLoading.value ||
|
||||
skillAccessFailed.value ||
|
||||
skillAccess.value?.canEdit === false ||
|
||||
isOutdated.value,
|
||||
);
|
||||
const cannotEdit = computed(() => skillAccess.value?.canEdit === false || isOutdated.value);
|
||||
const savedTarget = computed<AgentSkillAvailability | undefined>(() => {
|
||||
if (!skillAccess.value) return undefined;
|
||||
if (skillAccess.value.agentId) return 'agent';
|
||||
return skillAccess.value.scope === 'instance' ? 'instance' : 'project';
|
||||
});
|
||||
const usedByOtherAgents = computed(() => {
|
||||
const usage = skillAccess.value?.usedBy;
|
||||
if (!usage) return false;
|
||||
const isOtherAgent = ({ agentId }: { agentId: string }) => agentId !== props.data.agentId;
|
||||
return usage.hiddenAgents > 0 || usage.drafts.some(isOtherAgent) || usage.pins.some(isOtherAgent);
|
||||
});
|
||||
const showAvailabilitySelect = computed(() => {
|
||||
if (!props.data.hubSkillsEnabled || step.value === 'preview') return false;
|
||||
if (!isEditing.value) return true;
|
||||
return !!savedTarget.value && !cannotEdit.value;
|
||||
});
|
||||
const showInstanceTarget = computed(
|
||||
() => !!props.data.canCreateInstanceSkills || savedTarget.value === 'instance',
|
||||
);
|
||||
const AVAILABILITY_ICONS = {
|
||||
project: 'layers',
|
||||
agent: 'bot',
|
||||
instance: 'globe',
|
||||
} as const satisfies Record<AgentSkillAvailability, string>;
|
||||
const availabilityOptions = computed(() => [
|
||||
{ value: 'project' as const, label: i18n.baseText('agents.builder.skills.availableTo.project') },
|
||||
{ value: 'agent' as const, label: i18n.baseText('agents.builder.skills.availableTo.agent') },
|
||||
...(showInstanceTarget.value
|
||||
? [
|
||||
{
|
||||
value: 'instance' as const,
|
||||
label: i18n.baseText('agents.builder.skills.availableTo.instance'),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]);
|
||||
const removeLabel = computed(() =>
|
||||
props.data.hubSkillsEnabled
|
||||
? i18n.baseText('agents.builder.skills.detach')
|
||||
: i18n.baseText('agents.builder.skills.remove'),
|
||||
);
|
||||
const footerHint = computed(() => {
|
||||
const shownSkill = step.value === 'preview' ? previewSkill.value : skillAccess.value;
|
||||
if (step.value === 'edit' && isOutdated.value) {
|
||||
return i18n.baseText('agents.builder.skills.hint.outdated');
|
||||
}
|
||||
if (!shownSkill || (step.value !== 'preview' && step.value !== 'edit')) return '';
|
||||
if (!shownSkill.canEdit) {
|
||||
return shownSkill.scope === 'instance'
|
||||
? i18n.baseText('agents.builder.skills.hint.readOnly.instance')
|
||||
: i18n.baseText('agents.builder.skills.hint.readOnly.project');
|
||||
}
|
||||
if (step.value !== 'preview' || previewIsAdded.value) return '';
|
||||
return i18n.baseText('agents.builder.skills.hint.addToEdit');
|
||||
});
|
||||
|
||||
const isEditing = computed(() => !!props.data.skillId);
|
||||
const canAddReference = computed(
|
||||
() => (skill.value.references ?? []).length < AGENT_SKILL_REFERENCE_MAX_COUNT,
|
||||
);
|
||||
|
|
@ -93,11 +252,7 @@ const validationErrors = computed<Partial<Record<keyof AgentSkill, string>>>(()
|
|||
errors.name = i18n.baseText('agents.builder.skills.validation.nameRequired');
|
||||
} else if (name.length > 128) {
|
||||
errors.name = i18n.baseText('agents.builder.skills.validation.nameMaxLength');
|
||||
} else if (
|
||||
(props.data.existingSkillNames ?? []).some(
|
||||
(existingName) => existingName.trim().toLowerCase() === name.toLowerCase(),
|
||||
)
|
||||
) {
|
||||
} else if (existingNameKeys.value.has(skillNameKey(name))) {
|
||||
errors.name = i18n.baseText('agents.builder.skills.validation.nameDuplicate');
|
||||
}
|
||||
|
||||
|
|
@ -127,7 +282,115 @@ const visibleErrors = computed(() =>
|
|||
const visibleNameError = computed(() =>
|
||||
submitted.value ? (validationErrors.value.name ?? '') : '',
|
||||
);
|
||||
const canSave = computed(() => formIsValid.value && !validationErrors.value.name);
|
||||
const canSave = computed(
|
||||
() => formIsValid.value && !validationErrors.value.name && !isReadOnly.value,
|
||||
);
|
||||
const hasChanges = computed(
|
||||
() =>
|
||||
!isEditing.value ||
|
||||
!isEqual(skill.value, initialSkill) ||
|
||||
(showAvailabilitySelect.value && availableTo.value !== savedTarget.value),
|
||||
);
|
||||
|
||||
function getSimilarNameReason(name: string): string | undefined {
|
||||
const key = skillNameKey(name);
|
||||
const similarNames = attachedSkills.value
|
||||
.map((attachedSkill) => attachedSkill.name)
|
||||
.filter((attachedName) => skillNameKey(attachedName) === key);
|
||||
if (similarNames.length === 0) return undefined;
|
||||
return i18n.baseText('agents.builder.skills.picker.similarName', {
|
||||
interpolate: { names: [...similarNames, name].map((value) => `"${value}"`).join(', ') },
|
||||
});
|
||||
}
|
||||
|
||||
function getPickerCategory(librarySkill: SkillListItem): 'project' | 'this-agent' | 'global' {
|
||||
if (librarySkill.scope === 'instance') return 'global';
|
||||
return librarySkill.agentId ? 'this-agent' : 'project';
|
||||
}
|
||||
|
||||
async function loadLibrarySkills() {
|
||||
libraryLoading.value = true;
|
||||
try {
|
||||
librarySkills.value = await listAttachableSkills(rootStore.restApiContext, props.data.agentId);
|
||||
} catch (error) {
|
||||
showError(error, i18n.baseText('agents.builder.skills.picker.loadError'));
|
||||
} finally {
|
||||
libraryLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function targetForPickerCategory(category: ToolCategoryKey): AgentSkillAvailability {
|
||||
if (category === 'project') return 'project';
|
||||
if (category === 'global' && props.data.canCreateInstanceSkills) return 'instance';
|
||||
return 'agent';
|
||||
}
|
||||
|
||||
function onPickerCreate(category: ToolCategoryKey) {
|
||||
availableTo.value = targetForPickerCategory(category);
|
||||
step.value = 'upload';
|
||||
}
|
||||
|
||||
async function loadSkillAccess(skillId: string) {
|
||||
skillAccessLoading.value = true;
|
||||
try {
|
||||
skillAccess.value = await getHubSkill(rootStore.restApiContext, skillId);
|
||||
availableTo.value = savedTarget.value ?? 'project';
|
||||
} catch (error) {
|
||||
skillAccess.value = null;
|
||||
skillAccessFailed.value = true;
|
||||
showError(error, i18n.baseText('agents.builder.skills.accessError'));
|
||||
} finally {
|
||||
skillAccessLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onPickerItemAttach(item: ToolConnectionItem) {
|
||||
if (item.kind !== 'skill' || item.disabled || item.attached) return;
|
||||
void attachLibrarySkill(item.skillId);
|
||||
}
|
||||
|
||||
function onPickerItemDetach(item: ToolConnectionItem) {
|
||||
if (item.kind !== 'skill' || !item.attached || item.attaching) return;
|
||||
props.data.onRemove?.(item.skillId);
|
||||
}
|
||||
|
||||
async function onPickerItemPreview(item: ToolConnectionItem) {
|
||||
if (item.kind !== 'skill' || item.disabled) return;
|
||||
previewLoading.value = true;
|
||||
try {
|
||||
previewSkill.value = await getHubSkill(rootStore.restApiContext, item.skillId);
|
||||
previewPath.value = SKILL_FILE;
|
||||
step.value = 'preview';
|
||||
} catch (error) {
|
||||
showError(error, i18n.baseText('agents.builder.skills.picker.previewError'));
|
||||
} finally {
|
||||
previewLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function attachLibrarySkill(skillId: string) {
|
||||
const librarySkill = librarySkills.value.find((candidate) => candidate.id === skillId);
|
||||
if (!librarySkill || !props.data.onAttach) return;
|
||||
attachingSkillIds.value = new Set([...attachingSkillIds.value, skillId]);
|
||||
try {
|
||||
await props.data.onAttach(librarySkill);
|
||||
} finally {
|
||||
const remaining = new Set(attachingSkillIds.value);
|
||||
remaining.delete(skillId);
|
||||
attachingSkillIds.value = remaining;
|
||||
}
|
||||
}
|
||||
|
||||
function onPreviewAdd() {
|
||||
if (!previewSkill.value) return;
|
||||
void attachLibrarySkill(previewSkill.value.id);
|
||||
step.value = 'select';
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (hasPicker) void loadLibrarySkills();
|
||||
if (props.data.skillId && props.data.hubSkillsEnabled) void loadSkillAccess(props.data.skillId);
|
||||
});
|
||||
|
||||
function onSkillUploaded(importedSkill: AgentSkill) {
|
||||
skill.value = normalizeSkill(importedSkill);
|
||||
|
|
@ -137,6 +400,10 @@ function onSkillUploaded(importedSkill: AgentSkill) {
|
|||
}
|
||||
|
||||
function onBack() {
|
||||
if (step.value === 'upload' || step.value === 'preview') {
|
||||
step.value = 'select';
|
||||
return;
|
||||
}
|
||||
skill.value = { name: getDefaultSkillName(), description: '', instructions: '' };
|
||||
selectedPath.value = SKILL_FILE;
|
||||
submitted.value = false;
|
||||
|
|
@ -145,6 +412,7 @@ function onBack() {
|
|||
}
|
||||
|
||||
function onSkillUpdate(updates: Partial<AgentSkill>) {
|
||||
if (isReadOnly.value) return;
|
||||
skill.value = normalizeSkill({ ...skill.value, ...updates });
|
||||
if (
|
||||
selectedPath.value !== SKILL_FILE &&
|
||||
|
|
@ -221,11 +489,28 @@ function onSave() {
|
|||
...(skill.value.references ? { references: skill.value.references } : {}),
|
||||
});
|
||||
|
||||
props.data.onConfirm({ id: props.data.skillId, skill: payload });
|
||||
const targetChanged = !isEditing.value || availableTo.value !== savedTarget.value;
|
||||
props.data.onConfirm({
|
||||
id: props.data.skillId,
|
||||
skill: payload,
|
||||
...(showAvailabilitySelect.value && targetChanged ? { availableTo: availableTo.value } : {}),
|
||||
});
|
||||
closeModal();
|
||||
}
|
||||
|
||||
function onUseLatestVersion() {
|
||||
if (!props.data.skillId) return;
|
||||
const skillId = props.data.skillId;
|
||||
closeModal();
|
||||
props.data.onUseLatestVersion?.(skillId);
|
||||
}
|
||||
|
||||
function onRemove() {
|
||||
if (step.value === 'preview') {
|
||||
if (previewSkill.value) props.data.onRemove?.(previewSkill.value.id);
|
||||
step.value = 'select';
|
||||
return;
|
||||
}
|
||||
if (!props.data.skillId) return;
|
||||
props.data.onRemove?.(props.data.skillId);
|
||||
closeModal();
|
||||
|
|
@ -233,72 +518,200 @@ function onRemove() {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<AgentModal
|
||||
<AgentModalMultiStep
|
||||
:open="modalOpen"
|
||||
:title="step === 'upload' ? i18n.baseText('agents.builder.skills.add') : skill.name"
|
||||
:step="step"
|
||||
:title="modalTitle"
|
||||
:title-placeholder="i18n.baseText('agents.builder.skills.name.placeholder')"
|
||||
:title-max-length="128"
|
||||
:title-error="visibleNameError"
|
||||
:editable-title="step !== 'upload'"
|
||||
:show-back="step === 'manual'"
|
||||
:show-footer="step !== 'upload'"
|
||||
:busy="isImporting"
|
||||
:editable-title="step === 'manual' || (step === 'edit' && !isReadOnly)"
|
||||
:show-back="step === 'manual' || step === 'preview' || (step === 'upload' && hasPicker)"
|
||||
:show-footer="step === 'manual' || step === 'edit' || step === 'preview'"
|
||||
:body-flush="step !== 'select'"
|
||||
:busy="isImporting || previewLoading"
|
||||
size="fit"
|
||||
body-flush
|
||||
data-testid="agent-skill-modal"
|
||||
@update:open="!$event && closeModal()"
|
||||
@update:title="onSkillUpdate({ name: $event })"
|
||||
@back="onBack"
|
||||
>
|
||||
<N8nCallout
|
||||
v-if="openedWithMissingContent"
|
||||
theme="warning"
|
||||
:class="$style.missingContentCallout"
|
||||
data-testid="agent-skill-missing-content-callout"
|
||||
>
|
||||
{{ i18n.baseText('agents.builder.skills.missingContent.callout' as BaseTextKey) }}
|
||||
</N8nCallout>
|
||||
<div :class="$style.content">
|
||||
<AgentSkillUpload
|
||||
v-if="step === 'upload'"
|
||||
v-model:busy="isImporting"
|
||||
@uploaded="onSkillUploaded"
|
||||
@add-manually="step = 'manual'"
|
||||
@import:skill="onImportSkill"
|
||||
<div v-if="hasPicker" v-show="step === 'select'" :class="$style.picker">
|
||||
<ToolsConnectionModal
|
||||
:open="modalOpen && step === 'select'"
|
||||
:items="pickerItems"
|
||||
:categories="['all', 'project', 'this-agent', 'global']"
|
||||
:category-labels="data.projectLabel ? { project: data.projectLabel } : undefined"
|
||||
:detail-item="null"
|
||||
:search-placeholder="i18n.baseText('agents.builder.skills.picker.search.placeholder')"
|
||||
:empty-message="
|
||||
libraryLoading
|
||||
? i18n.baseText('agents.builder.skills.picker.loading')
|
||||
: i18n.baseText('agents.builder.skills.picker.empty')
|
||||
"
|
||||
:no-results-message="i18n.baseText('agents.builder.skills.picker.noResults')"
|
||||
:create-action="{
|
||||
label: i18n.baseText('agents.builder.skills.picker.create'),
|
||||
description: i18n.baseText('agents.builder.skills.picker.create.description'),
|
||||
testId: 'agent-skill-picker-create',
|
||||
}"
|
||||
embedded
|
||||
show-connect-actions
|
||||
persistent-scrollbar
|
||||
data-testid="agent-skill-picker"
|
||||
@connect="onPickerItemAttach"
|
||||
@disconnect="onPickerItemDetach"
|
||||
@open-detail="onPickerItemPreview"
|
||||
@create="onPickerCreate"
|
||||
/>
|
||||
<template v-else>
|
||||
<AgentSkillFileNav
|
||||
:skill="skill"
|
||||
:selected-path="selectedPath"
|
||||
:add-reference-disabled="!canAddReference"
|
||||
@add-reference="onAddReference"
|
||||
@remove-reference="onRemoveReference"
|
||||
@select="selectedPath = $event"
|
||||
/>
|
||||
<AgentSkillViewer
|
||||
:skill="skill"
|
||||
:selected-path="selectedPath"
|
||||
:errors="visibleErrors"
|
||||
:show-validation-warnings="submitted || openedWithMissingContent"
|
||||
@select:path="selectedPath = $event"
|
||||
@update:skill="onSkillUpdate"
|
||||
@update:valid="onValidUpdate"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
<div
|
||||
v-if="step === 'preview' && previewSkill"
|
||||
:class="$style.content"
|
||||
data-testid="agent-skill-preview"
|
||||
>
|
||||
<AgentSkillFileNav
|
||||
:skill="previewSkill.skill"
|
||||
:selected-path="previewPath"
|
||||
read-only
|
||||
@select="previewPath = $event"
|
||||
/>
|
||||
<AgentSkillViewer
|
||||
:skill="previewSkill.skill"
|
||||
:selected-path="previewPath"
|
||||
read-only
|
||||
@select:path="previewPath = $event"
|
||||
/>
|
||||
</div>
|
||||
<template v-else-if="step !== 'select'">
|
||||
<N8nCallout
|
||||
v-if="openedWithMissingContent && !isReadOnly"
|
||||
theme="warning"
|
||||
:class="$style.callout"
|
||||
data-testid="agent-skill-missing-content-callout"
|
||||
>
|
||||
{{ i18n.baseText('agents.builder.skills.missingContent.callout' as BaseTextKey) }}
|
||||
</N8nCallout>
|
||||
<div :class="$style.content">
|
||||
<AgentSkillUpload
|
||||
v-if="step === 'upload'"
|
||||
v-model:busy="isImporting"
|
||||
@uploaded="onSkillUploaded"
|
||||
@add-manually="step = 'manual'"
|
||||
@import:skill="onImportSkill"
|
||||
/>
|
||||
<template v-else>
|
||||
<AgentSkillFileNav
|
||||
:skill="skill"
|
||||
:selected-path="selectedPath"
|
||||
:add-reference-disabled="!canAddReference"
|
||||
:read-only="isReadOnly"
|
||||
@add-reference="onAddReference"
|
||||
@remove-reference="onRemoveReference"
|
||||
@select="selectedPath = $event"
|
||||
/>
|
||||
<AgentSkillViewer
|
||||
:skill="skill"
|
||||
:selected-path="selectedPath"
|
||||
:disabled="isReadOnly"
|
||||
:read-only="cannotEdit"
|
||||
:errors="visibleErrors"
|
||||
:show-validation-warnings="submitted || openedWithMissingContent"
|
||||
@select:path="selectedPath = $event"
|
||||
@update:skill="onSkillUpdate"
|
||||
@update:valid="onValidUpdate"
|
||||
>
|
||||
<template v-if="showAvailabilitySelect" #fields-start>
|
||||
<N8nInputLabel
|
||||
:label="i18n.baseText('agents.builder.skills.availableTo.label')"
|
||||
:tooltip-text="i18n.baseText('agents.builder.skills.availableTo.tooltip')"
|
||||
show-tooltip
|
||||
size="small"
|
||||
>
|
||||
<N8nTooltip
|
||||
:content="i18n.baseText('agents.builder.skills.availableTo.locked')"
|
||||
:disabled="!usedByOtherAgents"
|
||||
placement="top"
|
||||
>
|
||||
<N8nSelect
|
||||
v-model="availableTo"
|
||||
:teleported="false"
|
||||
:disabled="usedByOtherAgents"
|
||||
data-testid="agent-skill-availability-select"
|
||||
>
|
||||
<template #prefix>
|
||||
<N8nIcon
|
||||
:icon="AVAILABILITY_ICONS[availableTo]"
|
||||
:size="16"
|
||||
color="text-dark"
|
||||
/>
|
||||
</template>
|
||||
<N8nOption
|
||||
v-for="option in availabilityOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
:label="option.label"
|
||||
>
|
||||
<span :class="$style.availabilityOption">
|
||||
<N8nIcon :icon="AVAILABILITY_ICONS[option.value]" :size="16" />
|
||||
{{ option.label }}
|
||||
</span>
|
||||
</N8nOption>
|
||||
</N8nSelect>
|
||||
</N8nTooltip>
|
||||
</N8nInputLabel>
|
||||
</template>
|
||||
</AgentSkillViewer>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="isEditing && data.onRemove" #footerLeft>
|
||||
<template v-if="showRemove" #footerLeft>
|
||||
<N8nButton variant="ghost" data-testid="agent-skill-remove" @click="onRemove">
|
||||
<template #icon><N8nIcon icon="trash-2" :size="16" /></template>
|
||||
{{ i18n.baseText('agents.builder.skills.remove') }}
|
||||
<template #icon
|
||||
><N8nIcon :icon="data.hubSkillsEnabled ? 'unlink' : 'trash-2'" :size="16"
|
||||
/></template>
|
||||
{{ removeLabel }}
|
||||
</N8nButton>
|
||||
</template>
|
||||
<template #footerActions>
|
||||
<N8nButton variant="solid" data-testid="agent-skill-create-save" @click="onSave">
|
||||
<template v-if="footerHint" #footerBeforeCancel>
|
||||
<N8nText
|
||||
size="small"
|
||||
color="text-light"
|
||||
:class="$style.footerHint"
|
||||
data-testid="agent-skill-footer-hint"
|
||||
>
|
||||
{{ footerHint }}
|
||||
</N8nText>
|
||||
</template>
|
||||
<template v-if="footerPrimaryAction" #footerActions>
|
||||
<N8nButton
|
||||
v-if="footerPrimaryAction === 'add'"
|
||||
variant="solid"
|
||||
data-testid="agent-skill-preview-add"
|
||||
@click="onPreviewAdd"
|
||||
>
|
||||
{{ i18n.baseText('agents.builder.skills.picker.add') }}
|
||||
</N8nButton>
|
||||
<N8nButton
|
||||
v-else-if="footerPrimaryAction === 'useLatest'"
|
||||
variant="solid"
|
||||
data-testid="agent-skill-use-latest"
|
||||
@click="onUseLatestVersion"
|
||||
>
|
||||
{{ i18n.baseText('agents.builder.skills.useLatest') }}
|
||||
</N8nButton>
|
||||
<N8nButton
|
||||
v-else
|
||||
variant="solid"
|
||||
:disabled="!hasChanges"
|
||||
data-testid="agent-skill-create-save"
|
||||
@click="onSave"
|
||||
>
|
||||
{{ i18n.baseText('agents.builder.skills.save') }}
|
||||
</N8nButton>
|
||||
</template>
|
||||
</AgentModal>
|
||||
</AgentModalMultiStep>
|
||||
</template>
|
||||
|
||||
<style module>
|
||||
|
|
@ -312,10 +725,25 @@ function onRemove() {
|
|||
display: flex;
|
||||
}
|
||||
|
||||
.missingContentCallout {
|
||||
.picker {
|
||||
width: min(calc(52rem - var(--spacing--lg) * 2), calc(100dvw - var(--spacing--lg) * 5));
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.callout {
|
||||
margin: var(--spacing--md) var(--spacing--lg) 0;
|
||||
}
|
||||
|
||||
.footerHint {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.availabilityOption {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing--2xs);
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.content {
|
||||
flex-direction: column;
|
||||
|
|
|
|||
|
|
@ -6,7 +6,13 @@ import {
|
|||
AGENT_SKILL_REFERENCE_MAX_COUNT,
|
||||
AGENT_SKILL_REFERENCES_TOTAL_MAX_LENGTH,
|
||||
} from '@n8n/api-types';
|
||||
import { N8nFormInput, N8nInputLabel, N8nMarkdownEditor, N8nText } from '@n8n/design-system';
|
||||
import {
|
||||
N8nFormInput,
|
||||
N8nInputLabel,
|
||||
N8nMarkdown,
|
||||
N8nMarkdownEditor,
|
||||
N8nText,
|
||||
} from '@n8n/design-system';
|
||||
import type { IValidator, Validatable } from '@n8n/design-system';
|
||||
import { useI18n, type BaseTextKey } from '@n8n/i18n';
|
||||
|
||||
|
|
@ -14,17 +20,22 @@ import type { Rule, RuleGroup } from '@/Interface';
|
|||
import type { AgentSkill, AgentSkillReference } from '../types';
|
||||
|
||||
const SKILL_FILE = 'SKILL.md';
|
||||
const READ_ONLY_MARKDOWN_OPTIONS = {
|
||||
tasklists: { enabled: false, label: true, labelAfter: false },
|
||||
};
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
skill: AgentSkill;
|
||||
disabled?: boolean;
|
||||
readOnly?: boolean;
|
||||
errors?: Partial<Record<keyof AgentSkill, string>>;
|
||||
selectedPath?: string;
|
||||
showValidationWarnings?: boolean;
|
||||
}>(),
|
||||
{
|
||||
disabled: false,
|
||||
readOnly: false,
|
||||
selectedPath: SKILL_FILE,
|
||||
showValidationWarnings: false,
|
||||
},
|
||||
|
|
@ -257,7 +268,54 @@ watch(formIsValid, (valid) => emit('update:valid', valid), { immediate: true });
|
|||
|
||||
<template>
|
||||
<div :class="$style.panel" data-testid="agent-skill-viewer">
|
||||
<template v-if="isSkillFileSelected">
|
||||
<template v-if="props.readOnly">
|
||||
<template v-if="isSkillFileSelected">
|
||||
<N8nInputLabel
|
||||
:class="$style.readOnlyField"
|
||||
:label="i18n.baseText('agents.builder.skills.description.label')"
|
||||
size="medium"
|
||||
color="text-light"
|
||||
>
|
||||
<N8nText
|
||||
tag="p"
|
||||
color="text-base"
|
||||
:class="$style.readOnlyText"
|
||||
data-testid="agent-skill-description-text"
|
||||
>{{ props.skill.description }}</N8nText
|
||||
>
|
||||
</N8nInputLabel>
|
||||
<N8nInputLabel
|
||||
:class="$style.readOnlyField"
|
||||
:label="i18n.baseText('agents.builder.skills.instructions.label')"
|
||||
size="medium"
|
||||
color="text-light"
|
||||
>
|
||||
<N8nMarkdown
|
||||
:class="$style.readOnlyMarkdown"
|
||||
:options="READ_ONLY_MARKDOWN_OPTIONS"
|
||||
:content="props.skill.instructions"
|
||||
data-testid="agent-skill-instructions-text"
|
||||
/>
|
||||
</N8nInputLabel>
|
||||
</template>
|
||||
<N8nInputLabel
|
||||
v-else-if="selectedReference"
|
||||
:class="$style.readOnlyField"
|
||||
:label="selectedReference.path"
|
||||
size="medium"
|
||||
color="text-light"
|
||||
>
|
||||
<N8nMarkdown
|
||||
:class="$style.readOnlyMarkdown"
|
||||
:options="READ_ONLY_MARKDOWN_OPTIONS"
|
||||
:content="selectedReference.content"
|
||||
data-testid="agent-skill-reference-text"
|
||||
/>
|
||||
</N8nInputLabel>
|
||||
</template>
|
||||
|
||||
<template v-else-if="isSkillFileSelected">
|
||||
<slot name="fields-start" />
|
||||
<N8nText v-if="referencesError" size="small" color="danger">{{ referencesError }}</N8nText>
|
||||
<N8nText v-if="props.errors?.references && !referencesError" size="small" color="danger">{{
|
||||
props.errors.references
|
||||
|
|
@ -374,6 +432,46 @@ watch(formIsValid, (valid) => emit('update:valid', valid), { immediate: true });
|
|||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.readOnlyField {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--2xs);
|
||||
}
|
||||
|
||||
.readOnlyText {
|
||||
margin: 0;
|
||||
line-height: var(--line-height--xl);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.panel .readOnlyMarkdown {
|
||||
:global(p),
|
||||
:global(li) {
|
||||
font-size: var(--font-size--sm);
|
||||
line-height: var(--line-height--xl);
|
||||
}
|
||||
|
||||
:global(h1),
|
||||
:global(h2) {
|
||||
margin: var(--spacing--sm) 0 var(--spacing--2xs);
|
||||
font-size: var(--font-size--md);
|
||||
line-height: var(--line-height--lg);
|
||||
}
|
||||
|
||||
:global(h3),
|
||||
:global(h4),
|
||||
:global(h5),
|
||||
:global(h6) {
|
||||
margin: var(--spacing--xs) 0 var(--spacing--3xs);
|
||||
font-size: var(--font-size--sm);
|
||||
line-height: var(--line-height--lg);
|
||||
}
|
||||
|
||||
:global(:is(h1, h2, h3, h4, h5, h6):first-child) {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
<script setup lang="ts">
|
||||
import type { AgentConfigValidationIssue } from '@n8n/api-types';
|
||||
import { useToast } from '@n8n/composables/useToast';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { computed } from 'vue';
|
||||
|
||||
|
|
@ -40,23 +39,6 @@ const skillIssueMessages = computed(() =>
|
|||
);
|
||||
const outdatedSkillIds = computed(() => new Set(props.outdatedSkillIds));
|
||||
const outdatedWarning = computed(() => [i18n.baseText('agents.builder.skills.outdated.warning')]);
|
||||
const toast = useToast();
|
||||
|
||||
/**
|
||||
* An outdated skill cannot be edited: the hub refuses a save based on an older version.
|
||||
* Tell the user to remove the skill and add it again instead of opening the editor.
|
||||
*/
|
||||
function onOpenSkill(id: string) {
|
||||
if (outdatedSkillIds.value.has(id)) {
|
||||
toast.showMessage({
|
||||
type: 'warning',
|
||||
title: i18n.baseText('agents.builder.skills.outdated.title'),
|
||||
message: i18n.baseText('agents.builder.skills.outdated.warning'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
emit('open-skill', id);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -80,7 +62,7 @@ function onOpenSkill(id: string) {
|
|||
:disabled="props.disabled"
|
||||
:class="$style.skillChip"
|
||||
data-testid="agent-capabilities-skill-row"
|
||||
@click="onOpenSkill(id)"
|
||||
@click="emit('open-skill', id)"
|
||||
>
|
||||
{{ skill.name || id }}
|
||||
</AgentChipButton>
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ const props = withDefaults(
|
|||
showBack?: boolean;
|
||||
showFooter?: boolean;
|
||||
showCancel?: boolean;
|
||||
bodyFlush?: boolean;
|
||||
stableHeight?: boolean;
|
||||
busy?: boolean;
|
||||
size?: DialogSize;
|
||||
|
|
@ -31,6 +32,7 @@ const props = withDefaults(
|
|||
showBack: false,
|
||||
showFooter: undefined,
|
||||
showCancel: true,
|
||||
bodyFlush: false,
|
||||
stableHeight: false,
|
||||
busy: false,
|
||||
size: '2xlarge',
|
||||
|
|
@ -71,6 +73,7 @@ watch(
|
|||
:show-footer="showFooter"
|
||||
:show-cancel="showCancel"
|
||||
:body-scrollable="!isPickerStep"
|
||||
:body-flush="bodyFlush"
|
||||
:stable-height="stableHeight"
|
||||
:busy="busy"
|
||||
:size="size"
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import type {
|
|||
AgentN8nChatThreadSummary,
|
||||
AgentN8nChatThreadsResponse,
|
||||
AgentSkill,
|
||||
AgentSkillAvailability,
|
||||
AgentsSettingsDto,
|
||||
AgentSkillMutationResponse,
|
||||
AgentTaskConfig,
|
||||
|
|
@ -33,6 +34,9 @@ import type {
|
|||
AgentProviderModelsResponse,
|
||||
AgentVersionListItemDto,
|
||||
ChatIntegrationDescriptor,
|
||||
SkillDetail,
|
||||
SkillListItem,
|
||||
SkillListResponse,
|
||||
VectorStoreTestResult,
|
||||
} from '@n8n/api-types';
|
||||
import { getFullApiResponse, makeRestApiRequest, request } from '@n8n/rest-api-client';
|
||||
|
|
@ -703,12 +707,13 @@ export const createAgentSkill = async (
|
|||
projectId: string,
|
||||
agentId: string,
|
||||
skill: AgentSkill,
|
||||
availableTo?: AgentSkillAvailability,
|
||||
): Promise<AgentSkillMutationResponse> => {
|
||||
return await makeRestApiRequest<AgentSkillMutationResponse>(
|
||||
context,
|
||||
'POST',
|
||||
`/projects/${projectId}/agents/v2/${agentId}/skills`,
|
||||
skill,
|
||||
availableTo ? { ...skill, availableTo } : skill,
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -719,15 +724,37 @@ export const updateAgentSkill = async (
|
|||
skillId: string,
|
||||
updates: Partial<AgentSkill>,
|
||||
baseSkillHash?: string,
|
||||
availableTo?: AgentSkillAvailability,
|
||||
): Promise<AgentSkillMutationResponse> => {
|
||||
return await makeRestApiRequest<AgentSkillMutationResponse>(
|
||||
context,
|
||||
'PATCH',
|
||||
`/projects/${projectId}/agents/v2/${agentId}/skills/${skillId}`,
|
||||
{ ...updates, baseSkillHash },
|
||||
{ ...updates, baseSkillHash, ...(availableTo ? { availableTo } : {}) },
|
||||
);
|
||||
};
|
||||
|
||||
export const listAttachableSkills = async (
|
||||
context: IRestApiContext,
|
||||
agentId: string,
|
||||
): Promise<SkillListItem[]> => {
|
||||
const { data } = await makeRestApiRequest<SkillListResponse>(context, 'GET', '/skills', {
|
||||
attachableTo: agentId,
|
||||
});
|
||||
const targetRank = (skill: SkillListItem) =>
|
||||
skill.scope === 'instance' ? 2 : skill.agentId ? 1 : 0;
|
||||
const byTargetThenName = (left: SkillListItem, right: SkillListItem) =>
|
||||
targetRank(left) - targetRank(right) || left.name.localeCompare(right.name);
|
||||
return data.sort(byTargetThenName);
|
||||
};
|
||||
|
||||
export const getHubSkill = async (
|
||||
context: IRestApiContext,
|
||||
skillId: string,
|
||||
): Promise<SkillDetail> => {
|
||||
return await makeRestApiRequest<SkillDetail>(context, 'GET', `/skills/${skillId}`);
|
||||
};
|
||||
|
||||
export const getAgentBackgroundJobs = async (
|
||||
context: IRestApiContext,
|
||||
projectId: string,
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { setActivePinia } from 'pinia';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
|
|
@ -69,6 +71,7 @@ function makeActions(
|
|||
describe('useAgentCapabilitiesActions', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
setActivePinia(createTestingPinia());
|
||||
});
|
||||
|
||||
it('forwards the selected picker mode to the tools modal', () => {
|
||||
|
|
|
|||
|
|
@ -2,11 +2,20 @@ import { computed, type ComputedRef, type Ref } from 'vue';
|
|||
import { useI18n } from '@n8n/i18n';
|
||||
import { useRootStore } from '@n8n/stores/useRootStore';
|
||||
import { useToast } from '@n8n/composables/useToast';
|
||||
import type { AgentConfigValidationIssue } from '@n8n/api-types';
|
||||
import { useRBACStore } from '@n8n/stores/rbac.store';
|
||||
import { useSettingsStore } from '@n8n/stores/settings.store';
|
||||
import {
|
||||
skillNameKey,
|
||||
type AgentConfigValidationIssue,
|
||||
type AgentSkillAvailability,
|
||||
type SkillDetail,
|
||||
type SkillListItem,
|
||||
} from '@n8n/api-types';
|
||||
import { useUIStore } from '@/app/stores/ui.store';
|
||||
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
|
||||
import { AI_MCP_TOOL_NODE_TYPE } from '@/app/constants/nodeTypes';
|
||||
import { createAgentSkill } from './useAgentApi';
|
||||
import { createAgentSkill, getHubSkill } from './useAgentApi';
|
||||
import { useAgentProjectBreadcrumb } from './useAgentProjectBreadcrumb';
|
||||
import { mcpServerToNode } from './useMcpServerAdapter';
|
||||
import {
|
||||
AGENT_TOOLS_MODAL_KEY,
|
||||
|
|
@ -57,7 +66,11 @@ export interface UseAgentCapabilitiesActionsDeps {
|
|||
* `onOpenAddSkillModal` via the shared `createAgentSkill` API call.
|
||||
* Unused when `localSkills` is provided.
|
||||
*/
|
||||
scheduleSkillSave: (payload: { skillId: string; skill: AgentSkill }) => void;
|
||||
scheduleSkillSave: (payload: {
|
||||
skillId: string;
|
||||
skill: AgentSkill;
|
||||
availableTo?: AgentSkillAvailability;
|
||||
}) => void;
|
||||
/**
|
||||
* Skill store for hosts whose bodies live outside an agent entity — inline
|
||||
* agents keep them in the node parameter. `bodies` is where the handlers
|
||||
|
|
@ -83,6 +96,7 @@ export interface UseAgentCapabilitiesActionsDeps {
|
|||
* Hosts whose agent always exists omit it.
|
||||
*/
|
||||
ensureAgentPersisted?: () => Promise<void>;
|
||||
flushConfigSave?: () => Promise<unknown>;
|
||||
/**
|
||||
* Flushes pending agent edits before an API mutation that also writes the
|
||||
* agent config. This prevents the mutation from advancing the config hash
|
||||
|
|
@ -117,6 +131,7 @@ export function useAgentCapabilitiesActions(deps: UseAgentCapabilitiesActionsDep
|
|||
localSkills,
|
||||
supportsToolApproval,
|
||||
ensureAgentPersisted,
|
||||
flushConfigSave,
|
||||
beforeAgentMutation,
|
||||
refreshAgentAfterMutation,
|
||||
validationIssues,
|
||||
|
|
@ -127,7 +142,14 @@ export function useAgentCapabilitiesActions(deps: UseAgentCapabilitiesActionsDep
|
|||
const rootStore = useRootStore();
|
||||
const uiStore = useUIStore();
|
||||
const nodeTypesStore = useNodeTypesStore();
|
||||
const rbacStore = useRBACStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
const { showError, showMessage } = useToast();
|
||||
const { projectName } = useAgentProjectBreadcrumb(computed(() => projectId.value));
|
||||
|
||||
const hubSkillsEnabled = computed(
|
||||
() => !localSkills && settingsStore.moduleSettings.agents?.skillsHubEnabled === true,
|
||||
);
|
||||
|
||||
function onOpenAddToolModal(mode: ToolPickerMode = 'tools') {
|
||||
// Capture the target at open time: a confirm landing after an agent/node
|
||||
|
|
@ -327,11 +349,26 @@ export function useAgentCapabilitiesActions(deps: UseAgentCapabilitiesActionsDep
|
|||
skill,
|
||||
skillId: id,
|
||||
existingSkillNames: appliedSkillNames(id),
|
||||
hubSkillsEnabled: hubSkillsEnabled.value,
|
||||
outdated: hubSkillsEnabled.value && (agent.value?.outdatedSkillIds ?? []).includes(id),
|
||||
canCreateInstanceSkills: rbacStore.hasScope('skill:create'),
|
||||
onUseLatestVersion: (skillId: string) => {
|
||||
if (agentId.value !== targetAgentId) return;
|
||||
void useLatestSkillVersion(skillId);
|
||||
},
|
||||
onRemove: (skillId: string) => {
|
||||
if (agentId.value !== targetAgentId) return;
|
||||
onRemoveSkill(skillId);
|
||||
},
|
||||
onConfirm: ({ id: skillId, skill: updatedSkill }: { id?: string; skill: AgentSkill }) => {
|
||||
onConfirm: ({
|
||||
id: skillId,
|
||||
skill: updatedSkill,
|
||||
availableTo,
|
||||
}: {
|
||||
id?: string;
|
||||
skill: AgentSkill;
|
||||
availableTo?: AgentSkillAvailability;
|
||||
}) => {
|
||||
if (!skillId) return;
|
||||
if (agentId.value !== targetAgentId) return;
|
||||
const sanitizedSkill = normalizeAgentSkillForSave(updatedSkill);
|
||||
|
|
@ -353,7 +390,11 @@ export function useAgentCapabilitiesActions(deps: UseAgentCapabilitiesActionsDep
|
|||
[skillId]: sanitizedSkill,
|
||||
},
|
||||
};
|
||||
scheduleSkillSave({ skillId, skill: sanitizedSkill });
|
||||
scheduleSkillSave({
|
||||
skillId,
|
||||
skill: sanitizedSkill,
|
||||
...(availableTo && hubSkillsEnabled.value ? { availableTo } : {}),
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -369,10 +410,8 @@ export function useAgentCapabilitiesActions(deps: UseAgentCapabilitiesActionsDep
|
|||
* local-skill hosts. The modal validates against `existingSkillNames` before closing
|
||||
*/
|
||||
function hasDuplicateSkillName(name: string, excludeId?: string): boolean {
|
||||
const normalized = name.trim().toLowerCase();
|
||||
return appliedSkillNames(excludeId).some(
|
||||
(existing) => existing.trim().toLowerCase() === normalized,
|
||||
);
|
||||
const key = skillNameKey(name);
|
||||
return appliedSkillNames(excludeId).some((existing) => skillNameKey(existing) === key);
|
||||
}
|
||||
|
||||
function showDuplicateSkillNameError(name: string) {
|
||||
|
|
@ -411,7 +450,7 @@ export function useAgentCapabilitiesActions(deps: UseAgentCapabilitiesActionsDep
|
|||
scheduleConfigUpdate({ tasks: nextTasks });
|
||||
}
|
||||
|
||||
function onOpenAddSkillModal() {
|
||||
async function onOpenAddSkillModal() {
|
||||
telemetry?.trackOpenedAddSkillModal?.();
|
||||
|
||||
// Capture the target at open time: the modal can outlive an agent/node
|
||||
|
|
@ -423,13 +462,44 @@ export function useAgentCapabilitiesActions(deps: UseAgentCapabilitiesActionsDep
|
|||
const isCurrentTarget = () =>
|
||||
projectId.value === targetProjectId && agentId.value === targetAgentId;
|
||||
|
||||
if (hubSkillsEnabled.value && ensureAgentPersisted) {
|
||||
try {
|
||||
await ensureAgentPersisted();
|
||||
} catch (error) {
|
||||
showError(error, locale.baseText('agents.builder.skills.picker.loadError'));
|
||||
return;
|
||||
}
|
||||
if (!isCurrentTarget()) return;
|
||||
}
|
||||
|
||||
uiStore.openModalWithData({
|
||||
name: AGENT_SKILL_MODAL_KEY,
|
||||
data: {
|
||||
projectId: targetProjectId,
|
||||
agentId: targetAgentId,
|
||||
existingSkillNames: appliedSkillNames(),
|
||||
onConfirm: ({ skill }: { id?: string; skill: AgentSkill }) => {
|
||||
hubSkillsEnabled: hubSkillsEnabled.value,
|
||||
getAttachedSkills: () =>
|
||||
appliedSkills.value.map(({ id, skill }) => ({ id, name: skill.name })),
|
||||
projectLabel: projectName.value ?? undefined,
|
||||
canCreateInstanceSkills: rbacStore.hasScope('skill:create'),
|
||||
onRemove: (skillId: string) => {
|
||||
if (!isCurrentTarget()) return;
|
||||
onRemoveSkill(skillId);
|
||||
},
|
||||
onAttach: async (librarySkill: SkillListItem) =>
|
||||
await attachHubSkill(
|
||||
isCurrentTarget,
|
||||
async () => await getHubSkill(rootStore.restApiContext, librarySkill.id),
|
||||
),
|
||||
onConfirm: ({
|
||||
skill,
|
||||
availableTo,
|
||||
}: {
|
||||
id?: string;
|
||||
skill: AgentSkill;
|
||||
availableTo?: AgentSkillAvailability;
|
||||
}) => {
|
||||
if (localSkills) {
|
||||
if (!isCurrentTarget()) return;
|
||||
const sanitizedSkill = normalizeAgentSkillForSave(skill);
|
||||
|
|
@ -465,6 +535,7 @@ export function useAgentCapabilitiesActions(deps: UseAgentCapabilitiesActionsDep
|
|||
targetProjectId,
|
||||
targetAgentId,
|
||||
sanitizedSkill,
|
||||
hubSkillsEnabled.value ? availableTo : undefined,
|
||||
);
|
||||
skillId = result.id;
|
||||
created = result.skill;
|
||||
|
|
@ -510,6 +581,86 @@ export function useAgentCapabilitiesActions(deps: UseAgentCapabilitiesActionsDep
|
|||
});
|
||||
}
|
||||
|
||||
async function attachHubSkill(
|
||||
isCurrentTarget: () => boolean,
|
||||
loadSkill: () => Promise<SkillDetail>,
|
||||
) {
|
||||
let hubSkill: SkillDetail;
|
||||
try {
|
||||
hubSkill = await loadSkill();
|
||||
} catch (error) {
|
||||
showError(error, locale.baseText('agents.builder.skills.attach.error'));
|
||||
return;
|
||||
}
|
||||
if (!isCurrentTarget() || !agent.value || !localConfig.value) return;
|
||||
if (hasDuplicateSkillName(hubSkill.skill.name)) {
|
||||
showDuplicateSkillNameError(hubSkill.skill.name);
|
||||
return;
|
||||
}
|
||||
agent.value = {
|
||||
...agent.value,
|
||||
skillHashes: { ...(agent.value.skillHashes ?? {}), [hubSkill.id]: hubSkill.skillHash },
|
||||
skills: { ...(agent.value.skills ?? {}), [hubSkill.id]: hubSkill.skill },
|
||||
};
|
||||
scheduleConfigUpdate({
|
||||
skills: [
|
||||
...(localConfig.value.skills ?? []).filter((ref) => ref.id !== hubSkill.id),
|
||||
{ type: 'skill', id: hubSkill.id },
|
||||
],
|
||||
});
|
||||
if (!flushConfigSave) return;
|
||||
try {
|
||||
await flushConfigSave();
|
||||
} catch {
|
||||
if (isCurrentTarget()) undoRefusedAttach(hubSkill.id);
|
||||
}
|
||||
}
|
||||
|
||||
function undoRefusedAttach(skillId: string) {
|
||||
if (!agent.value || !localConfig.value) return;
|
||||
const withoutSkill = <T>(byId: Record<string, T> | undefined) =>
|
||||
Object.fromEntries(Object.entries(byId ?? {}).filter(([id]) => id !== skillId));
|
||||
agent.value = {
|
||||
...agent.value,
|
||||
skills: withoutSkill(agent.value.skills),
|
||||
skillHashes: withoutSkill(agent.value.skillHashes),
|
||||
};
|
||||
scheduleConfigUpdate({
|
||||
skills: (localConfig.value.skills ?? []).filter((ref) => ref.id !== skillId),
|
||||
});
|
||||
}
|
||||
|
||||
async function useLatestSkillVersion(skillId: string) {
|
||||
const targetAgentId = agentId.value;
|
||||
let hubSkill: SkillDetail;
|
||||
try {
|
||||
hubSkill = await getHubSkill(rootStore.restApiContext, skillId);
|
||||
} catch (error) {
|
||||
showError(error, locale.baseText('agents.builder.skills.useLatest.error'));
|
||||
return;
|
||||
}
|
||||
if (agentId.value !== targetAgentId || agent.value?.id !== targetAgentId) return;
|
||||
if (!localConfig.value) return;
|
||||
agent.value = {
|
||||
...agent.value,
|
||||
skills: { ...(agent.value.skills ?? {}), [skillId]: hubSkill.skill },
|
||||
skillHashes: { ...(agent.value.skillHashes ?? {}), [skillId]: hubSkill.skillHash },
|
||||
outdatedSkillIds: (agent.value.outdatedSkillIds ?? []).filter((id) => id !== skillId),
|
||||
};
|
||||
scheduleConfigUpdate({
|
||||
skills: (localConfig.value.skills ?? []).map((ref) =>
|
||||
ref.id === skillId ? { type: 'skill', id: skillId } : ref,
|
||||
),
|
||||
});
|
||||
try {
|
||||
await beforeAgentMutation?.();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (agentId.value !== targetAgentId) return;
|
||||
onOpenSkillFromList(skillId);
|
||||
}
|
||||
|
||||
function onConnectedTriggersUpdate(triggers: string[]) {
|
||||
connectedTriggers.value = triggers;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import {
|
|||
MAX_AGENT_KNOWLEDGE_BASE_SIZE_GB,
|
||||
addMissingAgentPersonalisation,
|
||||
type AgentFileDto,
|
||||
type AgentSkillAvailability,
|
||||
type InstanceAiHandoffContext,
|
||||
type PushMessage,
|
||||
type PushPayload,
|
||||
|
|
@ -1324,6 +1325,7 @@ interface SkillAutosaveSnapshot {
|
|||
skillId: string;
|
||||
skill: AgentSkill;
|
||||
baseSkillHash: string | undefined;
|
||||
availableTo?: AgentSkillAvailability;
|
||||
}
|
||||
|
||||
interface McpAvailabilitySnapshot {
|
||||
|
|
@ -1556,6 +1558,7 @@ async function saveSkill(snapshot: SkillAutosaveSnapshot): Promise<AutosaveResul
|
|||
snapshot.skillId,
|
||||
snapshot.skill,
|
||||
snapshot.baseSkillHash,
|
||||
snapshot.availableTo,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof ResponseError && error.httpStatusCode === 409) {
|
||||
|
|
@ -1981,11 +1984,12 @@ const caps = useAgentCapabilitiesActions({
|
|||
agentId,
|
||||
connectedTriggers,
|
||||
ensureAgentPersisted,
|
||||
flushConfigSave: flushConfigAutosave,
|
||||
beforeAgentMutation: flushAutosaveIgnoringResult,
|
||||
refreshAgentAfterMutation: onConfigUpdated,
|
||||
validationIssues: computed(() => configValidation.value?.issues ?? []),
|
||||
scheduleConfigUpdate: onConfigFieldUpdate,
|
||||
scheduleSkillSave: ({ skillId, skill }) => {
|
||||
scheduleSkillSave: ({ skillId, skill, availableTo }) => {
|
||||
// Acquire the write lock before persisting any change.
|
||||
agentCollaborationStore.requestWriteAccess();
|
||||
// The persisted validation result no longer reflects the working copy —
|
||||
|
|
@ -1998,6 +2002,7 @@ const caps = useAgentCapabilitiesActions({
|
|||
skillId,
|
||||
skill,
|
||||
baseSkillHash: agent.value?.skillHashes?.[skillId],
|
||||
...(availableTo ? { availableTo } : {}),
|
||||
});
|
||||
},
|
||||
telemetry: {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,14 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, inject, ref } from 'vue';
|
||||
import { N8nBadge, N8nButton, N8nIcon, N8nSpinner, N8nText, N8nTooltip } from '@n8n/design-system';
|
||||
import {
|
||||
N8nBadge,
|
||||
N8nButton,
|
||||
N8nIcon,
|
||||
N8nSpinner,
|
||||
N8nSwitch2,
|
||||
N8nText,
|
||||
N8nTooltip,
|
||||
} from '@n8n/design-system';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { RestrictedNodePopover } from '@n8n/frontend-module-type-availability-policies';
|
||||
import ToolCredentialPicker from './ToolCredentialPicker.vue';
|
||||
|
|
@ -24,6 +32,7 @@ const props = defineProps<{
|
|||
const emit = defineEmits<{
|
||||
'open-detail': [item: ToolConnectionItem];
|
||||
connect: [item: ToolConnectionItem];
|
||||
disconnect: [item: ToolConnectionItem];
|
||||
'select-credential': [item: ToolConnectionItem, authType: string, credentialId: string];
|
||||
'credential-dropdown-open': [item: ToolConnectionItem];
|
||||
'first-credential-connect': [item: ToolConnectionItem];
|
||||
|
|
@ -183,7 +192,11 @@ function handleConnect() {
|
|||
</template>
|
||||
|
||||
<template v-else>
|
||||
<ToolIcon :source="resolvedIcon" :fallback-icon="placeholderIcon" />
|
||||
<ToolIcon
|
||||
v-if="item.kind !== 'skill'"
|
||||
:source="resolvedIcon"
|
||||
:fallback-icon="placeholderIcon"
|
||||
/>
|
||||
<span :class="$style.text">
|
||||
<span :class="$style.titleRow">
|
||||
<N8nText :class="$style.title" tag="span" bold>{{ item.title }}</N8nText>
|
||||
|
|
@ -230,12 +243,7 @@ function handleConnect() {
|
|||
:anchor="rowRef"
|
||||
:side-offset="48"
|
||||
/>
|
||||
<N8nTooltip
|
||||
v-else-if="isDisabled"
|
||||
:content="item.disabledReason ?? ''"
|
||||
:disabled="!item.disabledReason"
|
||||
placement="top"
|
||||
>
|
||||
<N8nTooltip v-else-if="isDisabled" :disabled="!item.disabledReason" placement="top">
|
||||
<span
|
||||
:class="$style.disabledMarker"
|
||||
role="img"
|
||||
|
|
@ -245,7 +253,20 @@ function handleConnect() {
|
|||
>
|
||||
<N8nIcon icon="info" :size="14" color="text-light" />
|
||||
</span>
|
||||
<template #content>{{ item.disabledReason }}</template>
|
||||
</N8nTooltip>
|
||||
<N8nSwitch2
|
||||
v-else-if="item.kind === 'skill'"
|
||||
:model-value="item.attached"
|
||||
:disabled="item.attaching"
|
||||
:label="
|
||||
i18n.baseText(
|
||||
item.attached ? 'tools.connection.action.attached' : 'tools.connection.action.detached',
|
||||
)
|
||||
"
|
||||
data-test-id="tools-connection-row-attach"
|
||||
@update:model-value="$event ? emit('connect', item) : emit('disconnect', item)"
|
||||
/>
|
||||
<N8nButton
|
||||
v-else-if="item.kind === 'service'"
|
||||
variant="outline"
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ import {
|
|||
} from './types';
|
||||
|
||||
type PickerCreateAction = {
|
||||
category: ToolCategoryKey;
|
||||
category?: ToolCategoryKey;
|
||||
label: string;
|
||||
description?: string;
|
||||
testId?: string;
|
||||
|
|
@ -62,6 +62,7 @@ const props = withDefaults(
|
|||
connectLabel?: (item: ToolConnectionItem) => string;
|
||||
connectAriaLabel?: (item: ToolConnectionItem) => string;
|
||||
connectedLabel?: (item: ToolConnectionItem) => string;
|
||||
categoryLabels?: Partial<Record<ToolCategoryKey, string>>;
|
||||
}>(),
|
||||
{
|
||||
open: false,
|
||||
|
|
@ -92,7 +93,7 @@ const emit = defineEmits<{
|
|||
'new-credential-connect': [item: ToolConnectionItem];
|
||||
'open-detail': [item: ToolConnectionItem];
|
||||
connect: [item: ToolConnectionItem];
|
||||
create: [];
|
||||
create: [category: ToolCategoryKey];
|
||||
}>();
|
||||
|
||||
const i18n = useI18n();
|
||||
|
|
@ -278,11 +279,14 @@ const CATEGORY_I18N: Record<ToolCategoryKey, BaseTextKey> = {
|
|||
community: 'tools.connection.categories.community',
|
||||
workflows: 'tools.connection.categories.workflows',
|
||||
agents: 'tools.connection.categories.agents',
|
||||
project: 'tools.connection.categories.project',
|
||||
'this-agent': 'tools.connection.categories.thisAgent',
|
||||
global: 'tools.connection.categories.global',
|
||||
data: 'tools.connection.categories.data',
|
||||
};
|
||||
|
||||
function categoryLabel(category: ToolCategoryKey): string {
|
||||
return i18n.baseText(CATEGORY_I18N[category]);
|
||||
return props.categoryLabels?.[category] ?? i18n.baseText(CATEGORY_I18N[category]);
|
||||
}
|
||||
|
||||
const groupedSearchRows = computed<ListRow[]>(() => {
|
||||
|
|
@ -336,7 +340,12 @@ const resolvedEmptyMessage = computed(() => {
|
|||
}
|
||||
return props.emptyMessage ?? i18n.baseText('tools.connection.empty.title');
|
||||
});
|
||||
const showCreateAction = computed(() => props.createAction?.category === activeCategory.value);
|
||||
const showCreateAction = computed(
|
||||
() =>
|
||||
props.createAction !== undefined &&
|
||||
(props.createAction.category === undefined ||
|
||||
props.createAction.category === activeCategory.value),
|
||||
);
|
||||
|
||||
function openDetail(item: ToolConnectionItem) {
|
||||
emit('open-detail', item);
|
||||
|
|
@ -434,7 +443,7 @@ function handleOpenChange(value: boolean) {
|
|||
:disabled="createActionLoading"
|
||||
:aria-busy="createActionLoading"
|
||||
:data-test-id="createAction.testId ?? 'tools-connection-create'"
|
||||
@click="emit('create')"
|
||||
@click="emit('create', activeCategory)"
|
||||
>
|
||||
<span :class="$style.createIcon" aria-hidden="true">
|
||||
<N8nIcon
|
||||
|
|
@ -489,6 +498,7 @@ function handleOpenChange(value: boolean) {
|
|||
:connected-label="props.connectedLabel?.(row.item)"
|
||||
@open-detail="openDetail($event)"
|
||||
@connect="emit('connect', $event)"
|
||||
@disconnect="emit('disconnect', $event)"
|
||||
@select-credential="
|
||||
(item, authType, credentialId) =>
|
||||
emit('select-credential', item, authType, credentialId)
|
||||
|
|
@ -554,6 +564,7 @@ function handleOpenChange(value: boolean) {
|
|||
gap: var(--spacing--xs);
|
||||
width: 100%;
|
||||
min-height: 58px;
|
||||
margin-top: var(--spacing--sm);
|
||||
padding: var(--spacing--2xs);
|
||||
border: 0;
|
||||
border-radius: var(--radius--2xs);
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
type McpServerConnectionItem,
|
||||
type NodeConnectionItem,
|
||||
type ServiceConnectionItem,
|
||||
type SkillConnectionItem,
|
||||
type WorkflowConnectionItem,
|
||||
} from '../types';
|
||||
|
||||
|
|
@ -33,6 +34,7 @@ function render(
|
|||
| McpServerConnectionItem
|
||||
| NodeConnectionItem
|
||||
| ServiceConnectionItem
|
||||
| SkillConnectionItem
|
||||
| WorkflowConnectionItem,
|
||||
) {
|
||||
return renderRow({ props: { item }, pinia: createTestingPinia() });
|
||||
|
|
@ -99,6 +101,28 @@ const baseService: ServiceConnectionItem = {
|
|||
};
|
||||
|
||||
describe('ToolRow', () => {
|
||||
it('switches a detached skill on and an attached skill off', async () => {
|
||||
const skill: SkillConnectionItem = {
|
||||
id: 'skill:tone',
|
||||
kind: 'skill',
|
||||
skillId: 'tone',
|
||||
title: 'Support tone',
|
||||
status: 'none',
|
||||
attached: false,
|
||||
};
|
||||
const detached = render(skill);
|
||||
expect(detached.getByText('Detached')).toBeInTheDocument();
|
||||
await userEvent.click(detached.getByTestId('tools-connection-row-attach'));
|
||||
expect(detached.emitted('connect')).toEqual([[skill]]);
|
||||
detached.unmount();
|
||||
|
||||
const attachedSkill = { ...skill, attached: true };
|
||||
const attached = render(attachedSkill);
|
||||
expect(attached.getByText('Attached')).toBeInTheDocument();
|
||||
await userEvent.click(attached.getByTestId('tools-connection-row-attach'));
|
||||
expect(attached.emitted('disconnect')).toEqual([[attachedSkill]]);
|
||||
});
|
||||
|
||||
it('shows a Connect button for an available mcp-server and emits connect on click', async () => {
|
||||
const { getByTestId, emitted } = render(baseMcp);
|
||||
|
||||
|
|
@ -375,6 +399,28 @@ describe('ToolRow', () => {
|
|||
disabledReason: "Contains nodes that aren't supported as agent tools (Wait, Form)",
|
||||
};
|
||||
|
||||
it('shows the disabled reason as plain text', () => {
|
||||
const { getByTestId } = renderRow({
|
||||
props: {
|
||||
item: { ...disabledWorkflow, disabledReason: 'Rename <img src=x>_tag first' },
|
||||
},
|
||||
pinia: createTestingPinia(),
|
||||
global: {
|
||||
stubs: {
|
||||
N8nTooltip: {
|
||||
props: ['content'],
|
||||
template:
|
||||
'<span><slot /><span data-test-id="stub-tooltip-content"><span v-html="content" /><slot name="content" /></span></span>',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const content = getByTestId('stub-tooltip-content');
|
||||
expect(content.querySelector('img')).toBeNull();
|
||||
expect(content).toHaveTextContent('Rename <img src=x>_tag first');
|
||||
});
|
||||
|
||||
it('renders a disabled marker instead of a connect/install action', () => {
|
||||
const { getByTestId, queryByTestId } = render(disabledWorkflow);
|
||||
|
||||
|
|
|
|||
|
|
@ -94,7 +94,7 @@ function renderWith(
|
|||
showSuggestionFooter: boolean;
|
||||
groupSearchResults: boolean;
|
||||
createAction: {
|
||||
category: ToolCategoryKey;
|
||||
category?: ToolCategoryKey;
|
||||
label: string;
|
||||
testId?: string;
|
||||
};
|
||||
|
|
@ -342,7 +342,20 @@ describe('ToolsConnectionModal', () => {
|
|||
expect(getByTestId('tools-connection-empty')).toBeTruthy();
|
||||
|
||||
await fireEvent.click(getByTestId('tools-connection-create-workflow'));
|
||||
expect(emitted().create).toEqual([[]]);
|
||||
expect(emitted().create).toEqual([['workflows']]);
|
||||
});
|
||||
|
||||
it('offers the create action on every tab when it has no category', async () => {
|
||||
const { getByTestId } = renderWith({
|
||||
categories: ['all', 'mcp', 'workflows'],
|
||||
createAction: { label: 'Create', testId: 'tools-connection-create-any' },
|
||||
});
|
||||
|
||||
expect(getByTestId('tools-connection-create-any')).toBeTruthy();
|
||||
await fireEvent.click(getByTestId('tab-mcp'));
|
||||
expect(getByTestId('tools-connection-create-any')).toBeTruthy();
|
||||
await fireEvent.click(getByTestId('tab-workflows'));
|
||||
expect(getByTestId('tools-connection-create-any')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders the detail view when a detailItem is set', () => {
|
||||
|
|
|
|||
|
|
@ -11,7 +11,8 @@ export type ConnectionItemKind =
|
|||
| 'mcp-server'
|
||||
| 'agent'
|
||||
| 'data-store'
|
||||
| 'service';
|
||||
| 'service'
|
||||
| 'skill';
|
||||
|
||||
export type ToolIconSource =
|
||||
| { type: 'file'; src: string }
|
||||
|
|
@ -137,13 +138,21 @@ export interface ServiceConnectionItem extends BaseConnectionItem {
|
|||
serviceId: string;
|
||||
}
|
||||
|
||||
export interface SkillConnectionItem extends BaseConnectionItem {
|
||||
kind: 'skill';
|
||||
skillId: string;
|
||||
attached: boolean;
|
||||
attaching?: boolean;
|
||||
}
|
||||
|
||||
export type ToolConnectionItem =
|
||||
| NodeConnectionItem
|
||||
| WorkflowConnectionItem
|
||||
| McpServerConnectionItem
|
||||
| AgentConnectionItem
|
||||
| DataStoreConnectionItem
|
||||
| ServiceConnectionItem;
|
||||
| ServiceConnectionItem
|
||||
| SkillConnectionItem;
|
||||
|
||||
/**
|
||||
* One tab in the modal. Consumers declare the subset they support. `data` has
|
||||
|
|
@ -161,6 +170,9 @@ export type ToolCategoryKey =
|
|||
| 'community'
|
||||
| 'workflows'
|
||||
| 'agents'
|
||||
| 'project'
|
||||
| 'this-agent'
|
||||
| 'global'
|
||||
| 'data';
|
||||
|
||||
/** Used when an item carries no explicit `category`. */
|
||||
|
|
@ -171,6 +183,7 @@ export const CATEGORY_BY_KIND: Record<ConnectionItemKind, ToolCategoryKey> = {
|
|||
service: 'built-in',
|
||||
agent: 'agents',
|
||||
'data-store': 'data',
|
||||
skill: 'project',
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user