mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 13:39:44 +02:00
feat(editor): Support expressions in agent fields
This commit is contained in:
parent
c575e2e292
commit
eedd99ea68
|
|
@ -30,12 +30,14 @@ const {
|
|||
fetchAllCredentialsMock,
|
||||
fetchCredentialTypesMock,
|
||||
setCredentialsMock,
|
||||
fetchAllVariablesMock,
|
||||
agentPermissionsMock,
|
||||
} = vi.hoisted(() => ({
|
||||
fetchAllCredentialsForWorkflowMock: vi.fn().mockResolvedValue(undefined),
|
||||
fetchAllCredentialsMock: vi.fn().mockResolvedValue(undefined),
|
||||
fetchCredentialTypesMock: vi.fn().mockResolvedValue(undefined),
|
||||
setCredentialsMock: vi.fn(),
|
||||
fetchAllVariablesMock: vi.fn().mockResolvedValue(undefined),
|
||||
agentPermissionsMock: {
|
||||
canCreate: { value: true },
|
||||
canUpdate: { value: true },
|
||||
|
|
@ -85,6 +87,14 @@ vi.mock('@/features/credentials/credentials.store', () => ({
|
|||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/features/settings/environments.ee/environments.store', () => ({
|
||||
default: () => ({ fetchAllVariables: fetchAllVariablesMock }),
|
||||
}));
|
||||
|
||||
vi.mock('../components/AgentExpressionAwareMarkdownEditor.vue', () => ({
|
||||
default: { template: '<div />' },
|
||||
}));
|
||||
|
||||
vi.mock('@n8n/composables/useTelemetry', () => ({
|
||||
useTelemetry: () => ({ track: vi.fn() }),
|
||||
}));
|
||||
|
|
@ -329,7 +339,10 @@ async function renderView({
|
|||
setActivePinia(pinia);
|
||||
const { useSettingsStore } = await import('@/app/stores/settings.store');
|
||||
const settingsStore = useSettingsStore();
|
||||
settingsStore.settings = { activeModules: knowledgeBaseEnabled ? ['agents'] : [] } as never;
|
||||
settingsStore.settings = {
|
||||
activeModules: knowledgeBaseEnabled ? ['agents'] : [],
|
||||
enterprise: { variables: true },
|
||||
} as never;
|
||||
settingsStore.moduleSettings = {
|
||||
agents: {
|
||||
modules: [],
|
||||
|
|
@ -532,6 +545,7 @@ function resetViewMocks() {
|
|||
uploadAgentFilesMock.mockReset();
|
||||
uploadAgentFilesMock.mockResolvedValue([]);
|
||||
showErrorMock.mockReset();
|
||||
fetchAllVariablesMock.mockClear();
|
||||
fetchConfigMock.mockClear();
|
||||
builderTelemetryMock.fetchInitialTriggersBaseline.mockResolvedValue(null);
|
||||
favoritesStoreMock.isFavorite.mockReturnValue(false);
|
||||
|
|
@ -545,7 +559,7 @@ describe('AgentBuilderView — preview routing', () => {
|
|||
// 5s test timeout; warm the module once so each case measures mount behavior.
|
||||
beforeAll(async () => {
|
||||
await import('../views/AgentBuilderView.vue');
|
||||
}, 30_000);
|
||||
}, 60_000);
|
||||
|
||||
beforeEach(() => {
|
||||
resetViewMocks();
|
||||
|
|
@ -578,12 +592,13 @@ describe('AgentBuilderView — preview routing', () => {
|
|||
expect(wrapper.find('[data-testid="agent-chat-mode-toggle"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('loads credentials through the workflow-scoped credentials endpoint for the agent project', async () => {
|
||||
it('loads credentials and variables for the agent project', async () => {
|
||||
await renderView();
|
||||
|
||||
expect(setCredentialsMock).toHaveBeenCalledWith([]);
|
||||
expect(fetchAllCredentialsForWorkflowMock).toHaveBeenCalledWith({ projectId: 'p1' });
|
||||
expect(fetchAllCredentialsMock).not.toHaveBeenCalled();
|
||||
expect(fetchAllVariablesMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('persists a generated personalisation gradient when an existing agent is missing one', async () => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,120 @@
|
|||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { defineComponent, nextTick } from 'vue';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import AgentExpressionAwareMarkdownEditor from '../components/AgentExpressionAwareMarkdownEditor.vue';
|
||||
import AgentExpressionInput from '../components/AgentExpressionInput.vue';
|
||||
|
||||
const { completionStatusMock } = vi.hoisted(() => ({ completionStatusMock: vi.fn() }));
|
||||
|
||||
vi.mock('@codemirror/autocomplete', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@codemirror/autocomplete')>()),
|
||||
completionStatus: completionStatusMock,
|
||||
}));
|
||||
|
||||
const expressionFocus = vi.fn();
|
||||
|
||||
const ExpressionInputStub = defineComponent({
|
||||
name: 'AgentExpressionInput',
|
||||
props: ['modelValue', 'readonly', 'containerClass'],
|
||||
setup(_, { expose }) {
|
||||
expose({ focus: expressionFocus });
|
||||
return {};
|
||||
},
|
||||
template: '<div />',
|
||||
});
|
||||
|
||||
const MarkdownEditorStub = defineComponent({
|
||||
name: 'MarkdownEditor',
|
||||
props: ['modelValue'],
|
||||
emits: ['update:modelValue'],
|
||||
template: '<div />',
|
||||
});
|
||||
|
||||
const InlineExpressionEditorInputStub = defineComponent({
|
||||
name: 'InlineExpressionEditorInput',
|
||||
props: ['modelValue', 'additionalData'],
|
||||
emits: ['update:modelValue'],
|
||||
setup(_, { expose }) {
|
||||
expose({
|
||||
editor: { state: { doc: { toString: () => '{{ $vars.latest }}' } } },
|
||||
focus: vi.fn(),
|
||||
});
|
||||
},
|
||||
template: '<div />',
|
||||
});
|
||||
|
||||
function mountAwareEditor(
|
||||
modelValue: string,
|
||||
props: { readonly?: boolean; containerClass?: string } = {},
|
||||
) {
|
||||
return mount(AgentExpressionAwareMarkdownEditor, {
|
||||
props: { modelValue, ...props },
|
||||
global: {
|
||||
stubs: {
|
||||
AgentExpressionInput: ExpressionInputStub,
|
||||
N8nMarkdownEditor: MarkdownEditorStub,
|
||||
MarkdownEditor: MarkdownEditorStub,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe('AgentExpressionAwareMarkdownEditor', () => {
|
||||
it('keeps ordinary instructions in the Markdown editor and forwards updates', () => {
|
||||
const wrapper = mountAwareEditor('# Role\nHelp users.');
|
||||
const markdown = wrapper.findComponent(MarkdownEditorStub);
|
||||
|
||||
expect(markdown.props('modelValue')).toBe('# Role\nHelp users.');
|
||||
markdown.vm.$emit('update:modelValue', '# Role\nBe concise.');
|
||||
|
||||
expect(wrapper.emitted('update:modelValue')).toEqual([['# Role\nBe concise.']]);
|
||||
});
|
||||
|
||||
it('switches at a leading equals sign and preserves focus', async () => {
|
||||
expressionFocus.mockClear();
|
||||
const wrapper = mountAwareEditor('', { readonly: true, containerClass: 'fill-height' });
|
||||
|
||||
wrapper.findComponent(MarkdownEditorStub).vm.$emit('update:modelValue', '=');
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.findComponent(ExpressionInputStub).props()).toMatchObject({
|
||||
modelValue: '=',
|
||||
readonly: true,
|
||||
containerClass: 'fill-height',
|
||||
});
|
||||
expect(expressionFocus).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe('AgentExpressionInput', () => {
|
||||
it('adapts raw values and submits the current document after completion closes', async () => {
|
||||
completionStatusMock.mockReturnValue(null);
|
||||
const wrapper = mount(AgentExpressionInput, {
|
||||
props: { modelValue: '={{ $vars.team }}', submitOnEnter: true },
|
||||
global: {
|
||||
plugins: [createTestingPinia()],
|
||||
stubs: { InlineExpressionEditorInput: InlineExpressionEditorInputStub },
|
||||
},
|
||||
});
|
||||
const inline = wrapper.findComponent(InlineExpressionEditorInputStub);
|
||||
|
||||
expect(inline.props('modelValue')).toBe('{{ $vars.team }}');
|
||||
expect(inline.props('additionalData')).toHaveProperty('$vars');
|
||||
|
||||
inline.vm.$emit('update:modelValue', {
|
||||
value: '={{ $vars.department }}',
|
||||
segments: [],
|
||||
});
|
||||
|
||||
expect(wrapper.emitted('update:modelValue')).toEqual([['={{ $vars.department }}']]);
|
||||
|
||||
await inline.trigger('keydown', { key: 'Enter' });
|
||||
expect(wrapper.emitted('submit')).toEqual([['={{ $vars.latest }}']]);
|
||||
|
||||
completionStatusMock.mockReturnValue('active');
|
||||
await inline.trigger('keydown', { key: 'Enter' });
|
||||
expect(wrapper.emitted('submit')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
|
@ -57,16 +57,18 @@ vi.mock('@n8n/i18n', () => ({
|
|||
}));
|
||||
|
||||
vi.mock('@n8n/design-system', () => ({
|
||||
N8nMarkdownEditor: {
|
||||
name: 'N8nMarkdownEditor',
|
||||
props: ['modelValue', 'variant', 'showToolbar', 'placeholder', 'readonly', 'maxHeight'],
|
||||
emits: ['update:modelValue'],
|
||||
template:
|
||||
'<div v-bind="$attrs" data-testid="markdown-editor">{{ modelValue }} {{ placeholder }}</div>',
|
||||
},
|
||||
N8nText: { template: '<span><slot /></span>', props: ['tag', 'bold', 'size', 'color'] },
|
||||
}));
|
||||
|
||||
vi.mock('../components/AgentExpressionAwareMarkdownEditor.vue', () => ({
|
||||
default: {
|
||||
name: 'AgentExpressionAwareMarkdownEditor',
|
||||
props: ['modelValue', 'variant', 'showToolbar', 'maxHeight', 'disabled'],
|
||||
emits: ['update:modelValue'],
|
||||
template: '<div v-bind="$attrs" data-testid="expression-aware-editor">{{ modelValue }}</div>',
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/app/composables/useToast', () => ({
|
||||
useToast: () => ({ showError: vi.fn() }),
|
||||
}));
|
||||
|
|
@ -182,7 +184,7 @@ describe('AgentInfoPanel', () => {
|
|||
it('renders instructions as a contained markdown editor', () => {
|
||||
const wrapper = mountPanel();
|
||||
|
||||
const editor = wrapper.findComponent({ name: 'N8nMarkdownEditor' });
|
||||
const editor = wrapper.findComponent({ name: 'AgentExpressionAwareMarkdownEditor' });
|
||||
expect(editor.props()).toMatchObject({
|
||||
modelValue: '# Role\nHelp users.',
|
||||
variant: 'contained',
|
||||
|
|
@ -198,7 +200,7 @@ describe('AgentInfoPanel', () => {
|
|||
it('can show the markdown toolbar above instructions', () => {
|
||||
const wrapper = mountPanel('# Role\nHelp users.', { showInstructionsToolbar: true });
|
||||
|
||||
const editor = wrapper.findComponent({ name: 'N8nMarkdownEditor' });
|
||||
const editor = wrapper.findComponent({ name: 'AgentExpressionAwareMarkdownEditor' });
|
||||
expect(editor.props()).toMatchObject({
|
||||
showToolbar: 'always',
|
||||
variant: 'contained',
|
||||
|
|
@ -208,7 +210,7 @@ describe('AgentInfoPanel', () => {
|
|||
it('does not pass placeholder text to the instructions editor', () => {
|
||||
const wrapper = mountPanel('');
|
||||
|
||||
const editor = wrapper.findComponent({ name: 'N8nMarkdownEditor' });
|
||||
const editor = wrapper.findComponent({ name: 'AgentExpressionAwareMarkdownEditor' });
|
||||
expect(editor.props('modelValue')).toBe('');
|
||||
expect(editor.props('placeholder')).toBeUndefined();
|
||||
expect(wrapper.text()).not.toContain('Enter instructions here');
|
||||
|
|
|
|||
|
|
@ -9,6 +9,15 @@ import {
|
|||
|
||||
import AgentSkillViewer from '../components/AgentSkillViewer.vue';
|
||||
|
||||
vi.mock('../components/AgentExpressionAwareMarkdownEditor.vue', () => ({
|
||||
default: {
|
||||
name: 'AgentExpressionAwareMarkdownEditor',
|
||||
props: ['modelValue', 'readonly', 'path', 'showToolbar'],
|
||||
emits: ['update:modelValue'],
|
||||
template: '<div data-testid="expression-aware-skill-editor" />',
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@n8n/i18n', () => ({
|
||||
useI18n: () => ({
|
||||
baseText: (key: string, options?: { interpolate?: Record<string, string> }) => {
|
||||
|
|
@ -44,6 +53,37 @@ const N8nFormInputStub = defineComponent({
|
|||
});
|
||||
|
||||
describe('AgentSkillViewer', () => {
|
||||
it('uses expression-aware editing only for the SKILL.md instructions', () => {
|
||||
const skill = {
|
||||
name: 'Research',
|
||||
description: 'Use for research',
|
||||
instructions: 'Main body',
|
||||
references: [{ path: 'references/guide.md', content: '# Guide' }],
|
||||
};
|
||||
const instructions = mountViewer({ skill, disabled: true });
|
||||
const editor = instructions.findComponent({ name: 'AgentExpressionAwareMarkdownEditor' });
|
||||
expect(editor.props()).toMatchObject({
|
||||
readonly: true,
|
||||
path: 'agent.skill.instructions',
|
||||
showToolbar: 'always',
|
||||
});
|
||||
editor.vm.$emit('update:modelValue', '={{ $vars.skill }}');
|
||||
|
||||
expect(instructions.emitted('update:skill')?.at(-1)).toEqual([
|
||||
{ instructions: '={{ $vars.skill }}' },
|
||||
]);
|
||||
|
||||
const reference = mountViewer({
|
||||
skill,
|
||||
selectedPath: 'references/guide.md',
|
||||
});
|
||||
|
||||
expect(reference.findComponent({ name: 'AgentExpressionAwareMarkdownEditor' }).exists()).toBe(
|
||||
false,
|
||||
);
|
||||
expect(reference.find<HTMLTextAreaElement>('textarea').element.value).toBe('# Guide');
|
||||
});
|
||||
|
||||
it('imports SKILL.md frontmatter and instructions', async () => {
|
||||
const wrapper = mount(AgentSkillViewer, {
|
||||
props: {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,97 @@
|
|||
/* eslint-disable import-x/no-extraneous-dependencies -- test-only pattern */
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import AgentTelegramAccessSettingsForm from '../components/AgentTelegramAccessSettingsForm.vue';
|
||||
|
||||
vi.mock('@n8n/i18n', () => ({
|
||||
useI18n: () => ({ baseText: (key: string) => key }),
|
||||
i18n: { baseText: (key: string) => key },
|
||||
}));
|
||||
|
||||
const selectStub = {
|
||||
props: ['modelValue', 'disabled'],
|
||||
emits: ['update:modelValue'],
|
||||
template: `
|
||||
<select
|
||||
:value="modelValue"
|
||||
:disabled="disabled"
|
||||
@change="$emit('update:modelValue', $event.target.value)"
|
||||
>
|
||||
<slot />
|
||||
</select>
|
||||
`,
|
||||
};
|
||||
|
||||
const expressionInputStub = {
|
||||
name: 'AgentExpressionInput',
|
||||
props: ['modelValue', 'disabled', 'rows', 'embedded', 'submitOnEnter'],
|
||||
emits: ['update:modelValue', 'blur', 'submit'],
|
||||
methods: { focus: vi.fn() },
|
||||
template: '<textarea data-testid="expression-input" />',
|
||||
};
|
||||
|
||||
function mountForm(
|
||||
savedSettings: { accessMode: 'private' | 'public'; allowedUsers: string[] } = {
|
||||
accessMode: 'private',
|
||||
allowedUsers: [],
|
||||
},
|
||||
) {
|
||||
return mount(AgentTelegramAccessSettingsForm, {
|
||||
props: { savedSettings },
|
||||
global: {
|
||||
stubs: {
|
||||
AgentExpressionInput: expressionInputStub,
|
||||
N8nCallout: { template: '<div><slot /></div>' },
|
||||
N8nIcon: { template: '<i />' },
|
||||
N8nOption: { template: '<option />' },
|
||||
N8nSelect: selectStub,
|
||||
N8nText: { template: '<span><slot /></span>' },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe('AgentTelegramAccessSettingsForm', () => {
|
||||
it('preserves a loaded expression as valid saved state', () => {
|
||||
const expression = '={{ $vars.TELEGRAM_USER_ID }}';
|
||||
const wrapper = mountForm({ accessMode: 'private', allowedUsers: [expression] });
|
||||
|
||||
expect(wrapper.vm.currentSettings.allowedUsers).toEqual([expression]);
|
||||
expect(wrapper.vm.validationError).toBeNull();
|
||||
expect(wrapper.vm.isDirty).toBe(false);
|
||||
});
|
||||
|
||||
it('switches to autocomplete at = and commits the complete expression as one user', async () => {
|
||||
const wrapper = mountForm();
|
||||
const input = wrapper.get<HTMLInputElement>('#telegram-user-ids-input');
|
||||
const expression = '={{ $vars.TELEGRAM_USER_ID }}';
|
||||
|
||||
await input.setValue('=');
|
||||
const expressionInput = wrapper.getComponent(expressionInputStub);
|
||||
expect(expressionInput.props()).toMatchObject({ modelValue: '=', rows: 1 });
|
||||
|
||||
await expressionInput.trigger('keydown', { key: ' ' });
|
||||
expect(wrapper.vm.currentSettings.allowedUsers).toEqual([]);
|
||||
|
||||
const paste = new Event('paste', { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(paste, 'clipboardData', {
|
||||
value: { getData: () => '{{ $vars.PASTED_ID }}' },
|
||||
});
|
||||
expressionInput.element.dispatchEvent(paste);
|
||||
expect(wrapper.vm.currentSettings.allowedUsers).toEqual([]);
|
||||
|
||||
await expressionInput.vm.$emit('submit', expression);
|
||||
expect(wrapper.vm.currentSettings.allowedUsers).toEqual([expression]);
|
||||
});
|
||||
|
||||
it('retains literal bulk entry behavior', async () => {
|
||||
const wrapper = mountForm();
|
||||
const input = wrapper.get<HTMLInputElement>('#telegram-user-ids-input');
|
||||
|
||||
await input.setValue('@alice, 123 @bob');
|
||||
await input.trigger('blur');
|
||||
|
||||
expect(wrapper.vm.currentSettings.allowedUsers).toEqual(['@alice', '123', '@bob']);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
<script setup lang="ts">
|
||||
import { N8nMarkdownEditor } from '@n8n/design-system';
|
||||
import { computed, nextTick, ref, useAttrs, watch } from 'vue';
|
||||
|
||||
import AgentExpressionInput from './AgentExpressionInput.vue';
|
||||
|
||||
defineOptions({ inheritAttrs: false });
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: string;
|
||||
disabled?: boolean;
|
||||
readonly?: boolean;
|
||||
rows?: number;
|
||||
path?: string;
|
||||
showToolbar?: 'never' | 'hover' | 'always';
|
||||
maxHeight?: string | number;
|
||||
variant?: 'ghost' | 'contained';
|
||||
containerClass?: string;
|
||||
}>(),
|
||||
{
|
||||
disabled: false,
|
||||
readonly: false,
|
||||
rows: 5,
|
||||
path: 'agent.instructions',
|
||||
showToolbar: 'never',
|
||||
maxHeight: '480px',
|
||||
variant: 'contained',
|
||||
containerClass: '',
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string];
|
||||
focus: [];
|
||||
blur: [];
|
||||
}>();
|
||||
|
||||
const attrs = useAttrs();
|
||||
const value = ref(props.modelValue);
|
||||
const expressionInput = ref<InstanceType<typeof AgentExpressionInput>>();
|
||||
const markdownInput = ref<InstanceType<typeof N8nMarkdownEditor>>();
|
||||
const isExpression = computed(() => value.value.startsWith('='));
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(nextValue) => {
|
||||
if (nextValue !== value.value) value.value = nextValue;
|
||||
},
|
||||
);
|
||||
|
||||
async function updateValue(nextValue: string) {
|
||||
const wasExpression = isExpression.value;
|
||||
value.value = nextValue;
|
||||
emit('update:modelValue', nextValue);
|
||||
|
||||
if (wasExpression === isExpression.value) return;
|
||||
await nextTick();
|
||||
if (isExpression.value) expressionInput.value?.focus();
|
||||
else markdownInput.value?.focus();
|
||||
}
|
||||
|
||||
function focus() {
|
||||
if (isExpression.value) expressionInput.value?.focus();
|
||||
else markdownInput.value?.focus();
|
||||
}
|
||||
|
||||
defineExpose({ focus });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AgentExpressionInput
|
||||
v-if="isExpression"
|
||||
ref="expressionInput"
|
||||
v-bind="attrs"
|
||||
:model-value="value"
|
||||
:path="props.path"
|
||||
:rows="props.rows"
|
||||
:disabled="props.disabled"
|
||||
:readonly="props.readonly"
|
||||
:container-class="props.containerClass"
|
||||
:fill-height="Boolean(props.containerClass || attrs.class)"
|
||||
@update:model-value="updateValue"
|
||||
@focus="emit('focus')"
|
||||
@blur="emit('blur')"
|
||||
/>
|
||||
<N8nMarkdownEditor
|
||||
v-else
|
||||
ref="markdownInput"
|
||||
v-bind="attrs"
|
||||
:model-value="value"
|
||||
:disabled="props.disabled"
|
||||
:readonly="props.disabled || props.readonly"
|
||||
:show-toolbar="props.showToolbar"
|
||||
:max-height="props.maxHeight"
|
||||
:variant="props.variant"
|
||||
:container-class="props.containerClass"
|
||||
@update:model-value="updateValue"
|
||||
@focus="emit('focus')"
|
||||
@blur="emit('blur')"
|
||||
/>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,122 @@
|
|||
<script setup lang="ts">
|
||||
import { completionStatus } from '@codemirror/autocomplete';
|
||||
import { computed, ref } from 'vue';
|
||||
import type { IDataObject } from 'n8n-workflow';
|
||||
|
||||
import useEnvironmentsStore from '@/features/settings/environments.ee/environments.store';
|
||||
import InlineExpressionEditorInput from '@/features/shared/editors/components/InlineExpressionEditor/InlineExpressionEditorInput.vue';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: string;
|
||||
path?: string;
|
||||
rows?: number;
|
||||
disabled?: boolean;
|
||||
readonly?: boolean;
|
||||
embedded?: boolean;
|
||||
containerClass?: string;
|
||||
fillHeight?: boolean;
|
||||
submitOnEnter?: boolean;
|
||||
}>(),
|
||||
{
|
||||
path: 'agent.expression',
|
||||
rows: 5,
|
||||
disabled: false,
|
||||
readonly: false,
|
||||
embedded: false,
|
||||
containerClass: '',
|
||||
fillHeight: false,
|
||||
submitOnEnter: false,
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string];
|
||||
focus: [];
|
||||
blur: [];
|
||||
submit: [value: string];
|
||||
}>();
|
||||
|
||||
const environmentsStore = useEnvironmentsStore();
|
||||
const input = ref<InstanceType<typeof InlineExpressionEditorInput>>();
|
||||
const editorValue = computed(() =>
|
||||
props.modelValue.startsWith('=') ? props.modelValue.slice(1) : props.modelValue,
|
||||
);
|
||||
const additionalData = computed<IDataObject>(() => ({
|
||||
$vars: environmentsStore.variablesAsObject,
|
||||
}));
|
||||
|
||||
function toRawValue(value: string) {
|
||||
return value === '' || value.startsWith('=') ? value : `=${value}`;
|
||||
}
|
||||
|
||||
function onUpdate({ value }: { value: string }) {
|
||||
const rawValue = toRawValue(value);
|
||||
if (rawValue !== props.modelValue) emit('update:modelValue', rawValue);
|
||||
}
|
||||
|
||||
function focus() {
|
||||
input.value?.focus();
|
||||
}
|
||||
|
||||
function onKeydownCapture(event: KeyboardEvent) {
|
||||
if (!props.submitOnEnter || event.key !== 'Enter') return;
|
||||
const editor = input.value?.editor;
|
||||
if (editor && completionStatus(editor.state) !== null) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
emit('submit', toRawValue(editor?.state.doc.toString() ?? editorValue.value));
|
||||
}
|
||||
|
||||
defineExpose({ focus });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<InlineExpressionEditorInput
|
||||
ref="input"
|
||||
:model-value="editorValue"
|
||||
:path="props.path"
|
||||
:rows="props.rows"
|
||||
:is-read-only="props.disabled || props.readonly"
|
||||
:additional-data="additionalData"
|
||||
:class="[
|
||||
$style.expressionInput,
|
||||
props.containerClass,
|
||||
{ [$style.embedded]: props.embedded, [$style.fillHeight]: props.fillHeight },
|
||||
]"
|
||||
@update:model-value="onUpdate"
|
||||
@focus="emit('focus')"
|
||||
@focusout="emit('blur')"
|
||||
@keydown.capture="onKeydownCapture"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style module>
|
||||
.expressionInput {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.expressionInput :global(.cm-editor) {
|
||||
border-radius: var(--radius);
|
||||
background-color: var(--background--surface) !important;
|
||||
}
|
||||
|
||||
.fillHeight,
|
||||
.fillHeight :global(.cm-editor) {
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
.embedded {
|
||||
flex: 1 1 auto;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.embedded :global(.cm-editor),
|
||||
.embedded :global(.cm-content) {
|
||||
border: 0 !important;
|
||||
border-radius: 0;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -6,7 +6,7 @@
|
|||
*/
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useDebounceFn } from '@vueuse/core';
|
||||
import { N8nMarkdownEditor, N8nText } from '@n8n/design-system';
|
||||
import { N8nText } from '@n8n/design-system';
|
||||
import { AI_GATEWAY_MANAGED_TAG } from '@n8n/api-types';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
|
||||
|
|
@ -33,6 +33,7 @@ import { normalizePromptCachingForModelChange } from '../utils/promptCaching';
|
|||
import { normalizeReasoningForModelChange } from '../utils/reasoning';
|
||||
import AgentModelSelector from './AgentModelSelector.vue';
|
||||
import AgentPanelHeader from './AgentPanelHeader.vue';
|
||||
import AgentExpressionAwareMarkdownEditor from './AgentExpressionAwareMarkdownEditor.vue';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
|
|
@ -244,7 +245,7 @@ function onInstructionsInput(value: string) {
|
|||
i18n.baseText('agents.builder.agent.instructions.label')
|
||||
}}</N8nText>
|
||||
</label>
|
||||
<N8nMarkdownEditor
|
||||
<AgentExpressionAwareMarkdownEditor
|
||||
:class="$style.instructionsDocument"
|
||||
:model-value="instructions"
|
||||
:disabled="props.disabled"
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import { AgentSkillImportError, useAgentSkillImport } from '../composables/useAg
|
|||
import type { AgentSkill, AgentSkillReference } from '../types';
|
||||
import { formatToolNameForDisplay } from '../utils/toolDisplayName';
|
||||
import AgentChipButton from './AgentChipButton.vue';
|
||||
import AgentExpressionAwareMarkdownEditor from './AgentExpressionAwareMarkdownEditor.vue';
|
||||
|
||||
const SKILL_FILE = 'SKILL.md';
|
||||
|
||||
|
|
@ -568,11 +569,13 @@ watch(formIsValid, (valid) => emit('update:valid', valid), { immediate: true });
|
|||
:required="true"
|
||||
size="small"
|
||||
>
|
||||
<N8nMarkdownEditor
|
||||
<AgentExpressionAwareMarkdownEditor
|
||||
:class="$style.editor"
|
||||
:container-class="$style.fullHeightEditor"
|
||||
:model-value="props.skill.instructions ?? ''"
|
||||
:readonly="props.disabled"
|
||||
path="agent.skill.instructions"
|
||||
show-toolbar="always"
|
||||
max-height="100%"
|
||||
data-testid="agent-skill-instructions-editor"
|
||||
@update:model-value="onInstructionsInput"
|
||||
|
|
|
|||
|
|
@ -1,12 +1,17 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, computed, watch, nextTick } from 'vue';
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
import { N8nCallout, N8nIcon, N8nOption, N8nSelect, N8nText } from '@n8n/design-system';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import type { AgentTelegramIntegrationSettings } from '@n8n/api-types';
|
||||
import {
|
||||
AgentTelegramAllowedUserSchema,
|
||||
type AgentTelegramIntegrationSettings,
|
||||
} from '@n8n/api-types';
|
||||
import { isExpression } from 'n8n-workflow';
|
||||
|
||||
import AgentExpressionInput from './AgentExpressionInput.vue';
|
||||
import {
|
||||
DEFAULT_TELEGRAM_PUBLIC_SETTINGS,
|
||||
VALID_TELEGRAM_ENTRY_RE,
|
||||
normalizeTelegramUsers,
|
||||
type TelegramSettingsValidationError,
|
||||
} from '../utils/telegramAccessSettings';
|
||||
|
||||
|
|
@ -31,6 +36,7 @@ const accessMode = ref<AgentTelegramIntegrationSettings['accessMode']>(
|
|||
const entries = ref<string[]>(props.savedSettings?.allowedUsers.slice() ?? []);
|
||||
const inputText = ref('');
|
||||
const inputRef = ref<HTMLInputElement>();
|
||||
const expressionInputRef = ref<InstanceType<typeof AgentExpressionInput>>();
|
||||
|
||||
watch(
|
||||
() => props.savedSettings,
|
||||
|
|
@ -42,43 +48,54 @@ watch(
|
|||
},
|
||||
);
|
||||
|
||||
function finalizeInput() {
|
||||
const raw = inputText.value.trim();
|
||||
if (!raw) return;
|
||||
function focusInput() {
|
||||
if (isExpression(inputText.value)) expressionInputRef.value?.focus();
|
||||
else inputRef.value?.focus();
|
||||
}
|
||||
|
||||
const tokens = raw.split(/[\s,]+/).filter(Boolean);
|
||||
const unique = new Set(entries.value);
|
||||
for (const token of tokens) {
|
||||
unique.add(token);
|
||||
}
|
||||
entries.value = [...unique];
|
||||
function updateInputText(value: string) {
|
||||
const expressionModeChanged = isExpression(value) !== isExpression(inputText.value);
|
||||
inputText.value = value;
|
||||
if (expressionModeChanged) void nextTick(focusInput);
|
||||
}
|
||||
|
||||
function onInput(event: Event) {
|
||||
if (event.target instanceof HTMLInputElement) updateInputText(event.target.value);
|
||||
}
|
||||
|
||||
function finalizeInput(value = inputText.value) {
|
||||
const tokens = normalizeTelegramUsers([value]);
|
||||
if (tokens.length === 0) return;
|
||||
|
||||
entries.value = [...new Set([...entries.value, ...tokens])];
|
||||
inputText.value = '';
|
||||
}
|
||||
|
||||
function removeEntry(index: number) {
|
||||
entries.value = entries.value.filter((_, i) => i !== index);
|
||||
void nextTick(() => inputRef.value?.focus());
|
||||
void nextTick(focusInput);
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === ',' || e.key === ' ' || e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
if (event.defaultPrevented) return;
|
||||
const separator = event.key === ',' || event.key === ' ' || event.key === 'Enter';
|
||||
if (separator && (!isExpression(inputText.value) || event.key === 'Enter')) {
|
||||
event.preventDefault();
|
||||
finalizeInput();
|
||||
}
|
||||
if (e.key === 'Backspace' && inputText.value === '' && entries.value.length > 0) {
|
||||
if (event.key === 'Backspace' && inputText.value === '' && entries.value.length > 0) {
|
||||
entries.value = entries.value.slice(0, -1);
|
||||
}
|
||||
}
|
||||
|
||||
function onPaste(e: ClipboardEvent) {
|
||||
e.preventDefault();
|
||||
const pasted = e.clipboardData?.getData('text') ?? '';
|
||||
inputText.value += pasted;
|
||||
function onPaste(event: ClipboardEvent) {
|
||||
event.preventDefault();
|
||||
updateInputText(inputText.value + (event.clipboardData?.getData('text') ?? ''));
|
||||
finalizeInput();
|
||||
}
|
||||
|
||||
function onContainerClick() {
|
||||
inputRef.value?.focus();
|
||||
function isValidEntry(entry: string) {
|
||||
return AgentTelegramAllowedUserSchema.safeParse(entry).success;
|
||||
}
|
||||
|
||||
const currentSettings = computed<AgentTelegramIntegrationSettings>(() => ({
|
||||
|
|
@ -87,7 +104,7 @@ const currentSettings = computed<AgentTelegramIntegrationSettings>(() => ({
|
|||
}));
|
||||
|
||||
const invalidEntries = computed<string[]>(() =>
|
||||
entries.value.filter((entry) => !VALID_TELEGRAM_ENTRY_RE.test(entry)),
|
||||
entries.value.filter((entry) => !isValidEntry(entry)),
|
||||
);
|
||||
|
||||
const validationError = computed<TelegramSettingsValidationError | null>(() => {
|
||||
|
|
@ -148,12 +165,12 @@ defineExpose({ currentSettings, validationError, isDirty });
|
|||
<div
|
||||
:class="[$style.tagInput, { [$style.tagInputDisabled]: disabled }]"
|
||||
data-testid="telegram-user-ids"
|
||||
@click="onContainerClick"
|
||||
@click="focusInput"
|
||||
>
|
||||
<span
|
||||
v-for="(entry, idx) in entries"
|
||||
:key="entry + idx"
|
||||
:class="[$style.badge, { [$style.badgeInvalid]: !VALID_TELEGRAM_ENTRY_RE.test(entry) }]"
|
||||
:class="[$style.badge, { [$style.badgeInvalid]: !isValidEntry(entry) }]"
|
||||
>
|
||||
{{ entry }}
|
||||
<button
|
||||
|
|
@ -166,9 +183,26 @@ defineExpose({ currentSettings, validationError, isDirty });
|
|||
<N8nIcon icon="x" size="small" />
|
||||
</button>
|
||||
</span>
|
||||
<AgentExpressionInput
|
||||
v-if="isExpression(inputText)"
|
||||
ref="expressionInputRef"
|
||||
:model-value="inputText"
|
||||
:disabled="disabled"
|
||||
:rows="1"
|
||||
:class="$style.tagExpressionInput"
|
||||
path="agent.telegram.allowedUsers"
|
||||
embedded
|
||||
submit-on-enter
|
||||
@update:model-value="updateInputText"
|
||||
@keydown="onKeydown"
|
||||
@blur="finalizeInput()"
|
||||
@submit="finalizeInput"
|
||||
/>
|
||||
<input
|
||||
v-else
|
||||
id="telegram-user-ids-input"
|
||||
ref="inputRef"
|
||||
v-model="inputText"
|
||||
:value="inputText"
|
||||
:class="$style.tagInputField"
|
||||
:disabled="disabled"
|
||||
:placeholder="
|
||||
|
|
@ -176,9 +210,10 @@ defineExpose({ currentSettings, validationError, isDirty });
|
|||
? i18n.baseText('agents.builder.addTrigger.telegram.users.placeholder')
|
||||
: ''
|
||||
"
|
||||
@input="onInput"
|
||||
@keydown="onKeydown"
|
||||
@paste="onPaste"
|
||||
@blur="finalizeInput"
|
||||
@blur="finalizeInput()"
|
||||
/>
|
||||
</div>
|
||||
<N8nText
|
||||
|
|
@ -221,7 +256,7 @@ defineExpose({ currentSettings, validationError, isDirty });
|
|||
}
|
||||
|
||||
.error {
|
||||
color: var(--color--danger);
|
||||
color: var(--text-color--danger);
|
||||
}
|
||||
|
||||
.tagInput {
|
||||
|
|
@ -246,9 +281,13 @@ defineExpose({ currentSettings, validationError, isDirty });
|
|||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.tagInputField {
|
||||
.tagInputField,
|
||||
.tagExpressionInput {
|
||||
flex: 1;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.tagInputField {
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
import type { AgentIntegrationSettings, AgentTelegramIntegrationSettings } from '@n8n/api-types';
|
||||
import {
|
||||
AgentTelegramAllowedUserSchema,
|
||||
type AgentIntegrationSettings,
|
||||
type AgentTelegramIntegrationSettings,
|
||||
} from '@n8n/api-types';
|
||||
|
||||
export const DEFAULT_TELEGRAM_PUBLIC_SETTINGS = {
|
||||
accessMode: 'public',
|
||||
|
|
@ -21,25 +25,30 @@ export function resolveSavedTelegramSettings(
|
|||
|
||||
export type TelegramSettingsValidationError = 'required' | 'invalid';
|
||||
|
||||
// Matches a Telegram user ID (numeric) or username (letters, numbers, underscores),
|
||||
// optionally prefixed with "@".
|
||||
export const VALID_TELEGRAM_ENTRY_RE = /^@?[a-zA-Z0-9_]+$/;
|
||||
function splitTelegramUsersInput(input: string): string[] {
|
||||
const value = input.trim();
|
||||
if (!value) return [];
|
||||
if (value.startsWith('=')) return [value];
|
||||
return value.split(/[\s,]+/).filter(Boolean);
|
||||
}
|
||||
|
||||
export function normalizeTelegramUsers(entries: string[]): string[] {
|
||||
return [...new Set(entries.flatMap(splitTelegramUsersInput))];
|
||||
}
|
||||
|
||||
export function parseTelegramUsersInput(input: string): {
|
||||
allowedUsers: string[];
|
||||
invalidUsers: string[];
|
||||
} {
|
||||
const rawEntries = input
|
||||
.split(',')
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
const rawEntries = splitTelegramUsersInput(input);
|
||||
|
||||
const validEntries: string[] = [];
|
||||
const invalidEntries: string[] = [];
|
||||
|
||||
for (const entry of rawEntries) {
|
||||
if (VALID_TELEGRAM_ENTRY_RE.test(entry)) {
|
||||
validEntries.push(entry);
|
||||
const result = AgentTelegramAllowedUserSchema.safeParse(entry);
|
||||
if (result.success) {
|
||||
validEntries.push(result.data);
|
||||
} else {
|
||||
invalidEntries.push(entry);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,10 +25,11 @@ import { useTelemetry } from '@n8n/composables/useTelemetry';
|
|||
import { useToast } from '@/app/composables/useToast';
|
||||
import { useCredentialsStore } from '@/features/credentials/credentials.store';
|
||||
import { useSettingsStore } from '@/app/stores/settings.store';
|
||||
import useEnvironmentsStore from '@/features/settings/environments.ee/environments.store';
|
||||
import { useUIStore } from '@/app/stores/ui.store';
|
||||
import { useFavoritesStore } from '@/app/stores/favorites.store';
|
||||
import { useDocumentTitle } from '@/app/composables/useDocumentTitle';
|
||||
import { MODAL_CONFIRM } from '@/app/constants';
|
||||
import { EnterpriseEditionFeature, MODAL_CONFIRM } from '@/app/constants';
|
||||
import { deepCopy } from 'n8n-workflow';
|
||||
import {
|
||||
getAgent,
|
||||
|
|
@ -110,6 +111,7 @@ const { canSendPreviewToInstanceAi, sendPreviewSessionToInstanceAi } =
|
|||
const sessionsStore = useAgentSessionsStore();
|
||||
const credentialsStore = useCredentialsStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
const environmentsStore = useEnvironmentsStore();
|
||||
const uiStore = useUIStore();
|
||||
const favoritesStore = useFavoritesStore();
|
||||
const mcpStore = useMCPStore();
|
||||
|
|
@ -1180,6 +1182,9 @@ async function initialize() {
|
|||
fetchConfig(projectId.value, agentId.value),
|
||||
fetchAgentFiles(),
|
||||
refreshConfigValidation(projectId.value, agentId.value),
|
||||
settingsStore.isEnterpriseFeatureEnabled[EnterpriseEditionFeature.Variables]
|
||||
? environmentsStore.fetchAllVariables().catch(() => undefined)
|
||||
: Promise.resolve(),
|
||||
]);
|
||||
persistMissingPersonalisationGradient();
|
||||
builderTelemetry.captureToolsBaseline();
|
||||
|
|
|
|||
|
|
@ -11,11 +11,12 @@
|
|||
* features — the builder banner above nudges users there.
|
||||
*/
|
||||
import { computed, inject } from 'vue';
|
||||
import { N8nMarkdownEditor, N8nSectionHeader, N8nText } from '@n8n/design-system';
|
||||
import { N8nSectionHeader, N8nText } from '@n8n/design-system';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
|
||||
import AgentInfoPanel from '@/features/agents/components/AgentInfoPanel.vue';
|
||||
import AgentCapabilitiesSection from '@/features/agents/components/AgentCapabilitiesSection.vue';
|
||||
import AgentExpressionAwareMarkdownEditor from '@/features/agents/components/AgentExpressionAwareMarkdownEditor.vue';
|
||||
|
||||
import { NdvAgentConfigKey } from '../composables/useNdvAgentConfig';
|
||||
|
||||
|
|
@ -70,9 +71,10 @@ function onInstructionsUpdate(instructions: string) {
|
|||
<N8nText tag="label" step="sm" bold>
|
||||
{{ i18n.baseText('agents.builder.agent.instructions.label') }}
|
||||
</N8nText>
|
||||
<N8nMarkdownEditor
|
||||
<AgentExpressionAwareMarkdownEditor
|
||||
:model-value="localConfig?.instructions ?? ''"
|
||||
:disabled="!canUpdate"
|
||||
path="agent.ndv.instructions"
|
||||
show-toolbar="always"
|
||||
max-height="480px"
|
||||
variant="contained"
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ const InfoPanelStub = {
|
|||
};
|
||||
|
||||
const MarkdownEditorStub = {
|
||||
name: 'N8nMarkdownEditor',
|
||||
name: 'AgentExpressionAwareMarkdownEditor',
|
||||
props: ['modelValue', 'disabled'],
|
||||
emits: ['update:modelValue'],
|
||||
template: '<div data-testid="markdown-editor-stub" />',
|
||||
|
|
@ -109,7 +109,7 @@ function mountControls(ndv: UseNdvAgentConfigReturn, props: { isReadOnly?: boole
|
|||
stubs: {
|
||||
AgentCapabilitiesSection: CapabilitiesStub,
|
||||
AgentInfoPanel: InfoPanelStub,
|
||||
MarkdownEditor: MarkdownEditorStub,
|
||||
AgentExpressionAwareMarkdownEditor: MarkdownEditorStub,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -106,6 +106,12 @@ describe('No completions', () => {
|
|||
});
|
||||
|
||||
describe('Top-level completions', () => {
|
||||
test('should only offer variables without an active node', async () => {
|
||||
vi.spyOn(utils, 'hasActiveNode').mockReturnValue(false);
|
||||
|
||||
expect(await completions('{{ $| }}')).toEqual([expect.objectContaining({ label: '$vars' })]);
|
||||
});
|
||||
|
||||
test('should return dollar completions for blank position: {{ | }}', async () => {
|
||||
const result = await completions('{{ | }}');
|
||||
expect(result).toHaveLength(19);
|
||||
|
|
|
|||
|
|
@ -126,7 +126,7 @@ export async function dollarOptions(context: CompletionContext): Promise<Complet
|
|||
}
|
||||
|
||||
if (!hasActiveNode(workflowDocumentId, targetNodeParameterContext)) {
|
||||
return [];
|
||||
return ROOT_DOLLAR_COMPLETIONS.filter(({ label }) => label === '$vars');
|
||||
}
|
||||
|
||||
if (await receivesNoBinaryData(workflowDocumentId, targetNodeParameterContext?.nodeName))
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user