feat(editor): Support expressions in agent fields

This commit is contained in:
Michael Drury 2026-07-31 16:55:58 +01:00
parent c575e2e292
commit eedd99ea68
16 changed files with 625 additions and 62 deletions

View File

@ -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 () => {

View File

@ -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);
});
});

View File

@ -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');

View File

@ -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: {

View File

@ -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']);
});
});

View File

@ -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>

View File

@ -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>

View File

@ -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"

View File

@ -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"

View File

@ -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;

View File

@ -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);
}

View File

@ -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();

View File

@ -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"

View File

@ -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,
},
},
});

View File

@ -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);

View File

@ -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))