diff --git a/packages/frontend/editor-ui/src/app/components/WorkflowCard.vue b/packages/frontend/editor-ui/src/app/components/WorkflowCard.vue
index 1ed73bb670f..85b2a29b037 100644
--- a/packages/frontend/editor-ui/src/app/components/WorkflowCard.vue
+++ b/packages/frontend/editor-ui/src/app/components/WorkflowCard.vue
@@ -509,20 +509,27 @@ const tags = computed(
@click="onClick"
>
-
- {{ data.name }}
-
- {{ locale.baseText('workflows.item.readonly') }}
-
-
+
+ {{ data.name }}
+
+ {{ locale.baseText('workflows.item.readonly') }}
+
+
+
{
return sourceControlStore.preferences.branchReadOnly;
});
+// Show MCP action if:
+// - MCP module is active
+// - Instance-level access is enabled
+// - Workflow is eligible for MCP access
const isMcpAvailable = computed(() => {
- return settingsStore.isModuleActive('mcp') && isEligibleForMcpAccess(props.workflow);
+ return (
+ settingsStore.isModuleActive('mcp') &&
+ settingsStore.moduleSettings.mcp?.mcpAccessEnabled &&
+ isEligibleForMcpAccess(props.workflow)
+ );
});
const availableActions = computed(() => {
diff --git a/packages/frontend/editor-ui/src/app/components/WorkflowSettings.test.ts b/packages/frontend/editor-ui/src/app/components/WorkflowSettings.test.ts
index e1fb8aadbcf..acc74d5ba14 100644
--- a/packages/frontend/editor-ui/src/app/components/WorkflowSettings.test.ts
+++ b/packages/frontend/editor-ui/src/app/components/WorkflowSettings.test.ts
@@ -137,7 +137,7 @@ describe('WorkflowSettingsVue', () => {
expect(searchWorkflowsSpy).toHaveBeenCalledTimes(1);
expect(searchWorkflowsSpy).toHaveBeenCalledWith(
expect.objectContaining({
- name: undefined,
+ query: undefined,
}),
);
});
diff --git a/packages/frontend/editor-ui/src/app/components/WorkflowSettings.vue b/packages/frontend/editor-ui/src/app/components/WorkflowSettings.vue
index e05f1d09043..5816376535b 100644
--- a/packages/frontend/editor-ui/src/app/components/WorkflowSettings.vue
+++ b/packages/frontend/editor-ui/src/app/components/WorkflowSettings.vue
@@ -84,7 +84,9 @@ const defaultValues = ref({
availableInMCP: false,
});
-const isMCPEnabled = computed(() => settingsStore.isModuleActive('mcp'));
+const isMCPEnabled = computed(
+ () => settingsStore.isModuleActive('mcp') && settingsStore.moduleSettings.mcp?.mcpAccessEnabled,
+);
const readOnlyEnv = computed(() => sourceControlStore.preferences.branchReadOnly);
const workflowName = computed(() => workflowsStore.workflowName);
const workflowId = computed(() => workflowsStore.workflowId);
@@ -99,6 +101,17 @@ const workflowOwnerName = computed(() => {
});
const workflowPermissions = computed(() => getResourcePermissions(workflow.value?.scopes).workflow);
+const mcpToggleDisabled = computed(() => {
+ return readOnlyEnv.value || !workflowPermissions.value.update || !isEligibleForMcp.value;
+});
+
+const mcpToggleTooltip = computed(() => {
+ if (!isEligibleForMcp.value) {
+ return i18n.baseText('mcp.workflowNotEligable.description');
+ }
+ return i18n.baseText('workflowSettings.availableInMCP.tooltip');
+});
+
const isEligibleForMcp = computed(() => {
if (!workflow?.value) return false;
return isEligibleForMcpAccess(workflow.value);
@@ -279,7 +292,7 @@ const loadTimezones = async () => {
const loadWorkflows = async (searchTerm?: string) => {
const workflowsData = (await workflowsStore.searchWorkflows({
- name: searchTerm,
+ query: searchTerm,
})) as IWorkflowShortResponse[];
workflowsData.sort((a, b) => {
if (a.name.toLowerCase() < b.name.toLowerCase()) {
@@ -857,11 +870,7 @@ onBeforeUnmount(() => {
{{ i18n.baseText('workflowSettings.availableInMCP') }}
- {{
- isEligibleForMcp
- ? i18n.baseText('workflowSettings.availableInMCP.tooltip')
- : i18n.baseText('mcp.workflowNotEligable.description')
- }}
+ {{ mcpToggleTooltip }}
@@ -869,13 +878,13 @@ onBeforeUnmount(() => {
-
+
- {{ i18n.baseText('mcp.workflowNotEligable.description') }}
+ {{ mcpToggleTooltip }}
({
}));
import { useCanvasOperations } from '@/app/composables/useCanvasOperations';
+import { GRID_SIZE, PUSH_NODES_OFFSET } from '@/app/utils/nodeViewUtils';
vi.mock('n8n-workflow', async (importOriginal) => {
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
@@ -1072,6 +1073,41 @@ describe('useCanvasOperations', () => {
}),
);
});
+
+ it('should respect positionOffset', async () => {
+ const workflowsStore = mockedStore(useWorkflowsStore);
+ const nodeTypesStore = useNodeTypesStore();
+ const nodeTypeName = 'type';
+ const nodes: AddedNode[] = [
+ { name: 'Node 1', type: nodeTypeName },
+ { name: 'Node 2', type: nodeTypeName, positionOffset: [2 * GRID_SIZE, GRID_SIZE] },
+ ];
+
+ workflowsStore.workflowObject = createTestWorkflowObject(workflowsStore.workflow);
+
+ nodeTypesStore.nodeTypes = {
+ [nodeTypeName]: { 1: mockNodeTypeDescription({ name: nodeTypeName }) },
+ };
+
+ const { addNodes } = useCanvasOperations();
+ await addNodes(nodes, { position: [32, 32] });
+
+ expect(workflowsStore.addNode).toHaveBeenCalledTimes(2);
+ expect(workflowsStore.addNode.mock.calls[0][0]).toMatchObject({
+ name: nodes[0].name,
+ type: nodeTypeName,
+ typeVersion: 1,
+ position: [32, 32],
+ parameters: {},
+ });
+ expect(workflowsStore.addNode.mock.calls[1][0]).toMatchObject({
+ name: nodes[1].name,
+ type: nodeTypeName,
+ typeVersion: 1,
+ position: [32 + PUSH_NODES_OFFSET + 2 * GRID_SIZE, 32 + GRID_SIZE],
+ parameters: {},
+ });
+ });
});
describe('revertAddNode', () => {
diff --git a/packages/frontend/editor-ui/src/app/composables/useCanvasOperations.ts b/packages/frontend/editor-ui/src/app/composables/useCanvasOperations.ts
index 1e49a2ab63d..7154ccaf43e 100644
--- a/packages/frontend/editor-ui/src/app/composables/useCanvasOperations.ts
+++ b/packages/frontend/editor-ui/src/app/composables/useCanvasOperations.ts
@@ -716,10 +716,15 @@ export function useCanvasOperations() {
}
for (const [index, nodeAddData] of nodesWithTypeVersion.entries()) {
- const { isAutoAdd, openDetail: openNDV, actionName, ...node } = nodeAddData;
- const position = node.position ?? insertPosition;
- const nodeTypeDescription = requireNodeTypeDescription(node.type, node.typeVersion);
+ const { isAutoAdd, openDetail: openNDV, actionName, positionOffset, ...node } = nodeAddData;
+ const rawPosition = node.position ?? insertPosition;
+ const position: XYPosition | undefined =
+ rawPosition && positionOffset
+ ? [rawPosition[0] + positionOffset[0], rawPosition[1] + positionOffset[1]]
+ : rawPosition;
+
+ const nodeTypeDescription = requireNodeTypeDescription(node.type, node.typeVersion);
try {
const newNode = addNode(
{
diff --git a/packages/frontend/editor-ui/src/app/composables/useDataSchema.test.ts b/packages/frontend/editor-ui/src/app/composables/useDataSchema.test.ts
index 5c7c33ef050..902c24bf12b 100644
--- a/packages/frontend/editor-ui/src/app/composables/useDataSchema.test.ts
+++ b/packages/frontend/editor-ui/src/app/composables/useDataSchema.test.ts
@@ -365,6 +365,185 @@ describe('useDataSchema', () => {
);
expect(pathData).toEqual([new Date('2022-11-22T00:00:00.000Z')]);
});
+
+ describe('with collapseArrays=true', () => {
+ it('should collapse simple arrays to first item only', () => {
+ const input = ['John', 'Jane', 'Joe'];
+ const schema = getSchema(input, '', false, true);
+ expect(schema).toEqual({
+ type: 'array',
+ value: [{ type: 'string', value: 'John', key: '0', path: '[0]' }],
+ path: '',
+ });
+ });
+
+ it('should collapse nested arrays recursively', () => {
+ const input = [
+ { name: 'John', age: 22, hobbies: ['surfing', 'traveling', 'reading'] },
+ { name: 'Joe', age: 33, hobbies: ['skateboarding', 'gaming', 'coding'] },
+ { name: 'Jane', age: 28, hobbies: ['cooking', 'photography'] },
+ ];
+ const schema = getSchema(input, '', false, true);
+ expect(schema).toEqual({
+ type: 'array',
+ value: [
+ {
+ type: 'object',
+ key: '0',
+ value: [
+ { type: 'string', key: 'name', value: 'Jane', path: '[0].name' },
+ { type: 'number', key: 'age', value: '28', path: '[0].age' },
+ {
+ type: 'array',
+ key: 'hobbies',
+ value: [{ type: 'string', key: '0', value: 'cooking', path: '[0].hobbies[0]' }],
+ path: '[0].hobbies',
+ },
+ ],
+ path: '[0]',
+ },
+ ],
+ path: '',
+ });
+ });
+
+ it('should collapse nested arrays of objects with different keys recursively', () => {
+ const input = [
+ {
+ name: 'John',
+ age: 22,
+ createdAt: 193939,
+ },
+ { name: 'Joe', age: 33, hobbies: ['skateboarding', 'gaming', 'coding'], test: true },
+ { name: 'Jane', age: 28, hobbies: ['cooking', 'photography'], updatedAt: 199994 },
+ ];
+ const schema = getSchema(input, '', false, true);
+ expect(schema).toEqual({
+ type: 'array',
+ value: [
+ {
+ type: 'object',
+ key: '0',
+ value: [
+ { type: 'string', key: 'name', value: 'Jane', path: '[0].name' },
+ { type: 'number', key: 'age', value: '28', path: '[0].age' },
+ { type: 'number', key: 'createdAt', value: '193939', path: '[0].createdAt' },
+ {
+ type: 'array',
+ key: 'hobbies',
+ value: [{ type: 'string', key: '0', value: 'cooking', path: '[0].hobbies[0]' }],
+ path: '[0].hobbies',
+ },
+ { type: 'boolean', key: 'test', value: 'true', path: '[0].test' },
+ { type: 'number', key: 'updatedAt', value: '199994', path: '[0].updatedAt' },
+ ],
+ path: '[0]',
+ },
+ ],
+ path: '',
+ });
+ });
+
+ it('should handle empty arrays', () => {
+ const input: unknown[] = [];
+ const schema = getSchema(input, '', false, true);
+ expect(schema).toEqual({
+ type: 'array',
+ value: [],
+ path: '',
+ });
+ });
+
+ it('should collapse deeply nested arrays', () => {
+ const input = [
+ {
+ dates: [
+ [new Date('2022-11-22T00:00:00.000Z'), new Date('2022-11-23T00:00:00.000Z')],
+ [new Date('2022-12-22T00:00:00.000Z'), new Date('2022-12-23T00:00:00.000Z')],
+ ],
+ },
+ {
+ dates: [[new Date('2023-01-01T00:00:00.000Z'), new Date('2023-01-02T00:00:00.000Z')]],
+ },
+ ];
+ const schema = getSchema(input, '', false, true);
+ expect(schema).toEqual({
+ type: 'array',
+ value: [
+ {
+ type: 'object',
+ key: '0',
+ value: [
+ {
+ type: 'array',
+ key: 'dates',
+ value: [
+ {
+ type: 'array',
+ key: '0',
+ value: [
+ {
+ type: 'string',
+ key: '0',
+ value: '2023-01-01T00:00:00.000Z',
+ path: '[0].dates[0][0]',
+ },
+ ],
+ path: '[0].dates[0]',
+ },
+ ],
+ path: '[0].dates',
+ },
+ ],
+ path: '[0]',
+ },
+ ],
+ path: '',
+ });
+ });
+
+ it('should not affect objects, only arrays', () => {
+ const input = {
+ person1: { name: 'John', age: 22 },
+ person2: { name: 'Jane', age: 28 },
+ person3: { name: 'Joe', age: 33 },
+ };
+ const schema = getSchema(input, '', false, true);
+ expect(schema).toEqual({
+ type: 'object',
+ value: [
+ {
+ type: 'object',
+ key: 'person1',
+ value: [
+ { type: 'string', key: 'name', value: 'John', path: '.person1.name' },
+ { type: 'number', key: 'age', value: '22', path: '.person1.age' },
+ ],
+ path: '.person1',
+ },
+ {
+ type: 'object',
+ key: 'person2',
+ value: [
+ { type: 'string', key: 'name', value: 'Jane', path: '.person2.name' },
+ { type: 'number', key: 'age', value: '28', path: '.person2.age' },
+ ],
+ path: '.person2',
+ },
+ {
+ type: 'object',
+ key: 'person3',
+ value: [
+ { type: 'string', key: 'name', value: 'Joe', path: '.person3.name' },
+ { type: 'number', key: 'age', value: '33', path: '.person3.age' },
+ ],
+ path: '.person3',
+ },
+ ],
+ path: '',
+ });
+ });
+ });
});
describe('filterSchema', () => {
diff --git a/packages/frontend/editor-ui/src/app/composables/useDataSchema.ts b/packages/frontend/editor-ui/src/app/composables/useDataSchema.ts
index b85cba534bf..5489078c0c6 100644
--- a/packages/frontend/editor-ui/src/app/composables/useDataSchema.ts
+++ b/packages/frontend/editor-ui/src/app/composables/useDataSchema.ts
@@ -23,6 +23,7 @@ export function useDataSchema() {
input: Optional,
path = '',
excludeValues = false,
+ collapseArrays = false,
): Schema {
let schema: Schema = { type: 'undefined', value: 'undefined', path };
switch (typeof input) {
@@ -34,10 +35,24 @@ export function useDataSchema() {
} else if (Array.isArray(input)) {
schema = {
type: 'array',
- value: input.map((item, index) => ({
- key: index.toString(),
- ...getSchema(item, `${path}[${index}]`, excludeValues),
- })),
+ value:
+ collapseArrays && input.length > 0
+ ? [
+ {
+ key: '0',
+ ...getSchema(
+ // If array contains objects, merge all their keys into one
+ input.every((item) => isObj(item)) ? merge({}, ...input) : input[0],
+ `${path}[0]`,
+ excludeValues,
+ collapseArrays,
+ ),
+ },
+ ]
+ : input.map((item, index) => ({
+ key: index.toString(),
+ ...getSchema(item, `${path}[${index}]`, excludeValues, collapseArrays),
+ })),
path,
};
} else if (isObj(input)) {
@@ -45,7 +60,7 @@ export function useDataSchema() {
type: 'object',
value: Object.entries(input).map(([k, v]) => ({
key: k,
- ...getSchema(v, generatePath(path, [k]), excludeValues),
+ ...getSchema(v, generatePath(path, [k]), excludeValues, collapseArrays),
})),
path,
};
@@ -65,10 +80,14 @@ export function useDataSchema() {
return schema;
}
- function getSchemaForExecutionData(data: IDataObject[], excludeValues = false) {
+ function getSchemaForExecutionData(
+ data: IDataObject[],
+ excludeValues = false,
+ collapseArrays = false,
+ ) {
const [head, ...tail] = data;
- return getSchema(merge({}, head, ...tail, head), undefined, excludeValues);
+ return getSchema(merge({}, head, ...tail, head), undefined, excludeValues, collapseArrays);
}
function getSchemaForJsonSchema(schema: JSONSchema7 | JSONSchema7Definition, path = ''): Schema {
diff --git a/packages/frontend/editor-ui/src/app/composables/useDebugInfo.test.ts b/packages/frontend/editor-ui/src/app/composables/useDebugInfo.test.ts
index ae256c5b2d6..57ca5faa159 100644
--- a/packages/frontend/editor-ui/src/app/composables/useDebugInfo.test.ts
+++ b/packages/frontend/editor-ui/src/app/composables/useDebugInfo.test.ts
@@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest';
import { useDebugInfo } from './useDebugInfo';
import type { RootStoreState } from '@n8n/stores/useRootStore';
import type { useSettingsStore as useSettingsStoreType } from '@/app/stores/settings.store';
-import type { RecursivePartial } from '@/type-utils';
+import type { RecursivePartial } from '@/app/types/utils';
vi.mock('@n8n/stores/useRootStore', () => ({
useRootStore: (): Partial => ({
diff --git a/packages/frontend/editor-ui/src/app/composables/useWorkflowHelpers.ts b/packages/frontend/editor-ui/src/app/composables/useWorkflowHelpers.ts
index af25784fedc..14193d756b0 100644
--- a/packages/frontend/editor-ui/src/app/composables/useWorkflowHelpers.ts
+++ b/packages/frontend/editor-ui/src/app/composables/useWorkflowHelpers.ts
@@ -951,6 +951,7 @@ export function useWorkflowHelpers() {
workflowsStore.addWorkflow(workflowData);
workflowState.setActive(workflowData.active || false);
workflowsStore.setIsArchived(workflowData.isArchived);
+ workflowsStore.setDescription(workflowData.description);
workflowState.setWorkflowId(workflowData.id);
workflowState.setWorkflowName({
newName: workflowData.name,
diff --git a/packages/frontend/editor-ui/src/app/composables/useWorkflowSaving.test.ts b/packages/frontend/editor-ui/src/app/composables/useWorkflowSaving.test.ts
index 093c29dce8f..c9e7fbb08ac 100644
--- a/packages/frontend/editor-ui/src/app/composables/useWorkflowSaving.test.ts
+++ b/packages/frontend/editor-ui/src/app/composables/useWorkflowSaving.test.ts
@@ -1,7 +1,7 @@
import { useUIStore } from '@/app/stores/ui.store';
import { MODAL_CANCEL, MODAL_CONFIRM, PLACEHOLDER_EMPTY_WORKFLOW_ID, VIEWS } from '@/app/constants';
import { useWorkflowSaving } from './useWorkflowSaving';
-import router from '@/router';
+import router from '@/app/router';
import { createTestingPinia } from '@pinia/testing';
import { setActivePinia } from 'pinia';
import { useNpsSurveyStore } from '@/app/stores/npsSurvey.store';
diff --git a/packages/frontend/editor-ui/src/app/constants/enterprise.ts b/packages/frontend/editor-ui/src/app/constants/enterprise.ts
index b3f951ddc06..7a5fb98b24a 100644
--- a/packages/frontend/editor-ui/src/app/constants/enterprise.ts
+++ b/packages/frontend/editor-ui/src/app/constants/enterprise.ts
@@ -16,7 +16,6 @@ export const EnterpriseEditionFeature: Record<
ExternalSecrets: 'externalSecrets',
AuditLogs: 'auditLogs',
DebugInEditor: 'debugInEditor',
- WorkflowHistory: 'workflowHistory',
WorkerView: 'workerView',
AdvancedPermissions: 'advancedPermissions',
ApiKeyScopes: 'apiKeyScopes',
diff --git a/packages/frontend/editor-ui/src/app/constants/limits.ts b/packages/frontend/editor-ui/src/app/constants/limits.ts
index e00b3a65656..caebcec653c 100644
--- a/packages/frontend/editor-ui/src/app/constants/limits.ts
+++ b/packages/frontend/editor-ui/src/app/constants/limits.ts
@@ -3,4 +3,5 @@ export const MAX_EXPECTED_REQUEST_SIZE = 2048; // Expected maximum workflow requ
export const MAX_PINNED_DATA_SIZE = 1024 * 1024 * 12; // 12 MB; Workflow pinned data size limit in bytes
export const MAX_DISPLAY_DATA_SIZE = 1024 * 1024; // 1 MB
export const MAX_DISPLAY_DATA_SIZE_SCHEMA_VIEW = 1024 * 1024 * 4; // 4 MB
+export const MAX_DISPLAY_DATA_SIZE_LOGS_VIEW = 1024 * 512; // 512 KB
export const MAX_DISPLAY_ITEMS_AUTO_ALL = 250;
diff --git a/packages/frontend/editor-ui/src/app/constants/navigation.ts b/packages/frontend/editor-ui/src/app/constants/navigation.ts
index f412a5a150d..3963e99b9c8 100644
--- a/packages/frontend/editor-ui/src/app/constants/navigation.ts
+++ b/packages/frontend/editor-ui/src/app/constants/navigation.ts
@@ -62,6 +62,7 @@ export const enum VIEWS {
ENTITY_NOT_FOUND = 'EntityNotFound',
ENTITY_UNAUTHORIZED = 'EntityUnAuthorized',
PRE_BUILT_AGENT_TEMPLATES = 'PreBuiltAgentTemplates',
+ OAUTH_CONSENT = 'OAuthConsent',
}
export const EDITABLE_CANVAS_VIEWS = [VIEWS.WORKFLOW, VIEWS.NEW_WORKFLOW, VIEWS.EXECUTION_DEBUG];
diff --git a/packages/frontend/editor-ui/src/app/constants/workflowSuggestions.ts b/packages/frontend/editor-ui/src/app/constants/workflowSuggestions.ts
index 56aea83767b..e805c5a129d 100644
--- a/packages/frontend/editor-ui/src/app/constants/workflowSuggestions.ts
+++ b/packages/frontend/editor-ui/src/app/constants/workflowSuggestions.ts
@@ -6,51 +6,51 @@ export interface WorkflowSuggestion {
export const WORKFLOW_SUGGESTIONS: WorkflowSuggestion[] = [
{
- id: 'invoice-pipeline',
- summary: 'Invoice processing pipeline',
+ id: 'multi-agent-research',
+ summary: 'Multi-agent research workflow',
prompt:
- 'Create an invoice parsing workflow using n8n forms. Extract key information (vendor, date, amount, line items) using AI, validate the data, and store structured information in Airtable. Generate a weekly spending report every Sunday at 6 PM using AI analysis and send via email.',
- },
- {
- id: 'ai-news-digest',
- summary: 'Daily AI news digest',
- prompt:
- 'Create a workflow that fetches the latest AI news every morning at 8 AM. It should aggregate news from multiple sources, use LLM to summarize the top 5 stories, generate a relevant image using AI, and send everything as a structured Telegram message with article links. I should be able to chat about the news with the LLM so at least 40 last messages should be stored.',
- },
- {
- id: 'rag-assistant',
- summary: 'RAG knowledge assistant',
- prompt:
- 'Build a pipeline that accepts PDF, CSV, or JSON files through an n8n form. Chunk documents into 1000-token segments, generate embeddings, and store in a vector database. Use the filename as the document key and add metadata including upload date and file type. Include a chatbot that can answer questions based on a knowledge base.',
+ 'Create a multi-agent AI workflow using GPT-4.1-mini where several agents work together to research a topic, fact-check the findings, and write a report that\'s sent as an HTML email. One agent should gather recent, credible information about the topic. Another agent should verify the facts and only mark something as "verified" if it appears in at least two independent sources. A third agent should combine the verified information into a clear, well-written report under 1,000 words. A final agent should edit and format the report to make it look clean and professional in the body of the email. Use Gmail to send the report.',
},
{
id: 'email-summary',
summary: 'Summarize emails with AI',
prompt:
- 'Build a workflow that retrieves the last 50 emails from multiple email accounts. Merge all emails, perform AI analysis to identify action items, priorities, and sentiment. Generate a brief summary and send to Slack with categorized insights and recommended actions.',
+ 'Create an automation that runs on Monday mornings. It reads my Gmail inbox from the weekend, analyzes them with GPT-4.1-mini to find action items and priorities, and emails me a structured email using Gmail.',
},
{
- id: 'youtube-auto-chapters',
- summary: 'YouTube video chapters',
+ id: 'ai-news-digest',
+ summary: 'Daily AI news digest',
prompt:
- "I want to build an n8n workflow that automatically creates YouTube chapter timestamps by analyzing the video captions. When I trigger it manually, it should take a video ID as input, fetch the existing video metadata and captions from YouTube, use an AI language model like Google Gemini to parse the transcript into chapters with timestamps, and then update the video's description with these chapters appended. The goal is to save time and improve SEO by automating the whole process.",
+ 'Build an automation that runs every night 8pm. Use the NewsAPI "/everything" endpoint to search for AI-related news from the day. Pick the top 5 articles and use OpenAI GPT-4.1-mini to summarize each in two sentences. Generate an image using OpenAI based on the top article\'s summary. Send a structured Telegram message.',
},
{
- id: 'pizza-delivery',
- summary: 'Pizza delivery chatbot',
+ id: 'daily-weather-report',
+ summary: 'Daily weather report',
prompt:
- "I need an n8n workflow that creates a chatbot for my pizza delivery service. The bot should be able to answer customer questions about our pizza menu, take their orders accurately by capturing pizza type, quantity, and customer details, and also provide real-time updates when customers ask about their order status. It should use OpenAI's gpt-4.1-mini to handle conversations and integrate with HTTP APIs to get product info and manage orders. The workflow must maintain conversation context so the chatbot feels natural and can process multiple user queries sequentially.",
+ 'Create an automation that checks the weather for my location every morning at 5 a.m using OpenWeather. Send me a short weather report by email using Gmail. Use OpenAI GPT-4.1-mini to write a short, fun formatted email body by adding personality when describing the weather and how the day might feel. Include all details relevant to decide on my plans and clothes for the day.',
+ },
+ {
+ id: 'invoice-pipeline',
+ summary: 'Invoice processing pipeline',
+ prompt:
+ 'Create an invoice processing workflow using an n8n Form. When a user submits an invoice file (PDF or image) with their email address, use OpenAI GPT-4.1-mini to extract invoice data. Then, validate the date format is correct, the currency is valid, and the total amount is greater than zero. If validation fails, email the user a clear error message that explains which check failed from my Gmail. If the data passes validation, store the structured result in a datatable plus email the user. Every Monday morning, generate a weekly spending report using GPT-4.1-mini based on stored invoices and send a clean email using Gmail.',
+ },
+ {
+ id: 'rag-assistant',
+ summary: 'RAG knowledge assistant',
+ prompt:
+ 'Build an automation that creates a document-to-chat RAG pipeline. The workflow starts with an n8n Form where a user uploads one or more files (PDF, CSV, or JSON). Each upload should trigger a process that reads the file, splits it into chunks, and generates embeddings using OpenAI GPT-4.1-mini model, saved in one Pinecone table. Add a second part of the workflow for querying: use a Chat Message Trigger to act as a chatbot interface. When a user sends a question, retrieve the top 5 most relevant chunks from Pinecone, pass them into GPT-4.1-mini as context, and have it answer naturally using only the retrieved information. If a question can\'t be answered confidently, the bot should respond with: "I couldn\'t find that in the uploaded documents." Log each chat interaction in a Data Table with the user query, matched file(s), and timestamp. Send a daily summary email through Gmail showing total questions asked, top files referenced, and any failed lookups.',
},
{
id: 'lead-qualification',
summary: 'Lead qualification and call scheduling',
prompt:
- 'Create a form with fields for email, company, and role. Build an automation that processes form submissions, enrich with company data from their website, uses AI to qualify the lead, sends data to Google Sheets. For high-score leads it should also schedule a 15-min call in a free slot in my calendar and send a confirmation email to both me and the lead.',
+ 'Create an n8n form with a lead generation form I can embed on my website homepage. Build an automation that processes form submissions, uses AI to qualify the lead, sends data to an n8n data table. For high-score leads, it should also email them to offer to schedule a 15-min call in a free slot in my calendar.',
},
{
- id: 'multi-agent-research',
- summary: 'Multi-agent research workflow',
+ id: 'youtube-auto-chapters',
+ summary: 'YouTube video chapters',
prompt:
- 'Create a multi-agent AI workflow where different AI agents collaborate to research a topic, fact-check information, and compile comprehensive reports.',
+ "Build an n8n workflow that automatically generates YouTube chapter timestamps from video captions. Use the n8n chat trigger for me to enter the URL of the YouTube video. Use the YouTube Get a video node to get the video title, description, and existing metadata. Use the YouTube Captions API to download the transcript for the given video ID. Send the transcript to AI agent using Anthropic's Claude model. Prompt the model to identify topic shifts and return structured output in timestamp - chapter format. Append the generated chapter list to the existing video description. Use the YouTube Update a video node to update the video description. Respond back with the updates using the respond to chat node.",
},
];
diff --git a/packages/frontend/editor-ui/src/app/styles/_animations.scss b/packages/frontend/editor-ui/src/app/css/_animations.scss
similarity index 100%
rename from packages/frontend/editor-ui/src/app/styles/_animations.scss
rename to packages/frontend/editor-ui/src/app/css/_animations.scss
diff --git a/packages/frontend/editor-ui/src/n8n-theme.scss b/packages/frontend/editor-ui/src/app/css/_global.scss
similarity index 97%
rename from packages/frontend/editor-ui/src/n8n-theme.scss
rename to packages/frontend/editor-ui/src/app/css/_global.scss
index da4fe7f6a84..dc40b596b60 100644
--- a/packages/frontend/editor-ui/src/n8n-theme.scss
+++ b/packages/frontend/editor-ui/src/app/css/_global.scss
@@ -1,9 +1,5 @@
-@use '@n8n/design-system/css/mixins' as ds-mixins;
-@use '@n8n/chat/css';
-@use '@/app/styles';
-
:root {
- // Using native css variable enables us to use this value in JS
+ --navbar--height: 64px;
--header--height: 65;
--content-container--width: 1280px;
--banner--height: 48px;
diff --git a/packages/frontend/editor-ui/src/n8n-theme-variables.scss b/packages/frontend/editor-ui/src/app/css/_variables.scss
similarity index 98%
rename from packages/frontend/editor-ui/src/n8n-theme-variables.scss
rename to packages/frontend/editor-ui/src/app/css/_variables.scss
index fdf1b885603..257dae2d240 100644
--- a/packages/frontend/editor-ui/src/n8n-theme-variables.scss
+++ b/packages/frontend/editor-ui/src/app/css/_variables.scss
@@ -92,3 +92,5 @@ $version-card-release-date-text-color: var(--color--foreground--shade-2);
// supplemental node types
$supplemental-node-types: ai_chain ai_document ai_embedding ai_languageModel ai_memory
ai_outputParser ai_tool ai_retriever ai_textSplitter ai_vectorRetriever ai_vectorStore;
+
+$ease-out-expo: cubic-bezier(0.19, 1, 0.22, 1);
diff --git a/packages/frontend/editor-ui/src/app/styles/index.scss b/packages/frontend/editor-ui/src/app/css/index.scss
similarity index 100%
rename from packages/frontend/editor-ui/src/app/styles/index.scss
rename to packages/frontend/editor-ui/src/app/css/index.scss
diff --git a/packages/frontend/editor-ui/src/app/styles/plugins/_codemirror.scss b/packages/frontend/editor-ui/src/app/css/plugins/_codemirror.scss
similarity index 100%
rename from packages/frontend/editor-ui/src/app/styles/plugins/_codemirror.scss
rename to packages/frontend/editor-ui/src/app/css/plugins/_codemirror.scss
diff --git a/packages/frontend/editor-ui/src/app/styles/plugins/_vueflow.scss b/packages/frontend/editor-ui/src/app/css/plugins/_vueflow.scss
similarity index 100%
rename from packages/frontend/editor-ui/src/app/styles/plugins/_vueflow.scss
rename to packages/frontend/editor-ui/src/app/css/plugins/_vueflow.scss
diff --git a/packages/frontend/editor-ui/src/app/styles/plugins/index.scss b/packages/frontend/editor-ui/src/app/css/plugins/index.scss
similarity index 100%
rename from packages/frontend/editor-ui/src/app/styles/plugins/index.scss
rename to packages/frontend/editor-ui/src/app/css/plugins/index.scss
diff --git a/packages/frontend/editor-ui/src/dev/i18nHmr.ts b/packages/frontend/editor-ui/src/app/dev/i18nHmr.ts
similarity index 100%
rename from packages/frontend/editor-ui/src/dev/i18nHmr.ts
rename to packages/frontend/editor-ui/src/app/dev/i18nHmr.ts
diff --git a/packages/frontend/editor-ui/src/init.test.ts b/packages/frontend/editor-ui/src/app/init.test.ts
similarity index 99%
rename from packages/frontend/editor-ui/src/init.test.ts
rename to packages/frontend/editor-ui/src/app/init.test.ts
index 0fb71a9a517..432d0c20223 100644
--- a/packages/frontend/editor-ui/src/init.test.ts
+++ b/packages/frontend/editor-ui/src/app/init.test.ts
@@ -1,6 +1,6 @@
import { mockedStore, SETTINGS_STORE_DEFAULT_STATE } from '@/__tests__/utils';
import { EnterpriseEditionFeature } from '@/app/constants';
-import { initializeAuthenticatedFeatures, initializeCore, state } from '@/init';
+import { initializeAuthenticatedFeatures, initializeCore, state } from '@/app/init';
import { UserManagementAuthenticationMethod } from '@/Interface';
import { useCloudPlanStore } from '@/app/stores/cloudPlan.store';
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
diff --git a/packages/frontend/editor-ui/src/init.ts b/packages/frontend/editor-ui/src/app/init.ts
similarity index 99%
rename from packages/frontend/editor-ui/src/init.ts
rename to packages/frontend/editor-ui/src/app/init.ts
index dffee71c64e..0108e24f444 100644
--- a/packages/frontend/editor-ui/src/init.ts
+++ b/packages/frontend/editor-ui/src/app/init.ts
@@ -1,3 +1,4 @@
+import '@/app/polyfills';
import SourceControlInitializationErrorMessage from '@/features/integrations/sourceControl.ee/components/SourceControlInitializationErrorMessage.vue';
import { useExternalHooks } from '@/app/composables/useExternalHooks';
import { useTelemetry } from '@/app/composables/useTelemetry';
diff --git a/packages/frontend/editor-ui/src/polyfills.ts b/packages/frontend/editor-ui/src/app/polyfills.ts
similarity index 100%
rename from packages/frontend/editor-ui/src/polyfills.ts
rename to packages/frontend/editor-ui/src/app/polyfills.ts
diff --git a/packages/frontend/editor-ui/src/router.test.ts b/packages/frontend/editor-ui/src/app/router.test.ts
similarity index 92%
rename from packages/frontend/editor-ui/src/router.test.ts
rename to packages/frontend/editor-ui/src/app/router.test.ts
index 5813c9247cd..15729bc51ff 100644
--- a/packages/frontend/editor-ui/src/router.test.ts
+++ b/packages/frontend/editor-ui/src/app/router.test.ts
@@ -1,6 +1,6 @@
import { createPinia, setActivePinia } from 'pinia';
import { createComponentRenderer } from '@/__tests__/render';
-import router from '@/router';
+import router from '@/app/router';
import { SSO_JUST_IN_TIME_PROVSIONING_EXPERIMENT, VIEWS } from '@/app/constants';
import { setupServer } from '@/__tests__/server';
import { useSettingsStore } from '@/app/stores/settings.store';
@@ -8,7 +8,7 @@ import { usePostHog } from '@/app/stores/posthog.store';
import { useRBACStore } from '@/app/stores/rbac.store';
import type { Scope } from '@n8n/permissions';
import type { RouteRecordName } from 'vue-router';
-import * as init from '@/init';
+import * as init from '@/app/init';
const App = {
template: '',
@@ -61,11 +61,7 @@ describe('router', () => {
10000,
);
- test.each([
- ['/workflow/R9JFXwkUCL1jZBuw/debug/29021', VIEWS.WORKFLOWS],
- ['/workflow/8IFYawZ9dKqJu8sT/history', VIEWS.WORKFLOWS],
- ['/workflow/8IFYawZ9dKqJu8sT/history/6513ed960252b846f3792f0c', VIEWS.WORKFLOWS],
- ])(
+ test.each([['/workflow/R9JFXwkUCL1jZBuw/debug/29021', VIEWS.WORKFLOWS]])(
'should redirect %s to %s if user does not have permissions',
async (path, name) => {
await router.push(path);
@@ -75,17 +71,12 @@ describe('router', () => {
10000,
);
- test.each([
- ['/workflow/R9JFXwkUCL1jZBuw/debug/29021', VIEWS.EXECUTION_DEBUG],
- ['/workflow/8IFYawZ9dKqJu8sT/history', VIEWS.WORKFLOW_HISTORY],
- ['/workflow/8IFYawZ9dKqJu8sT/history/6513ed960252b846f3792f0c', VIEWS.WORKFLOW_HISTORY],
- ])(
+ test.each([['/workflow/R9JFXwkUCL1jZBuw/debug/29021', VIEWS.EXECUTION_DEBUG]])(
'should resolve %s to %s if user has permissions',
async (path, name) => {
const settingsStore = useSettingsStore();
settingsStore.settings.enterprise.debugInEditor = true;
- settingsStore.settings.enterprise.workflowHistory = true;
await router.push(path);
expect(initializeAuthenticatedFeaturesSpy).toHaveBeenCalled();
@@ -94,6 +85,19 @@ describe('router', () => {
10000,
);
+ test.each([
+ ['/workflow/8IFYawZ9dKqJu8sT/history', VIEWS.WORKFLOW_HISTORY],
+ ['/workflow/8IFYawZ9dKqJu8sT/history/6513ed960252b846f3792f0c', VIEWS.WORKFLOW_HISTORY],
+ ])(
+ 'should resolve %s to %s (available to all users)',
+ async (path, name) => {
+ await router.push(path);
+ expect(initializeAuthenticatedFeaturesSpy).toHaveBeenCalled();
+ expect(router.currentRoute.value.name).toBe(name);
+ },
+ 10000,
+ );
+
test.each<[string, RouteRecordName, Scope[]]>([
['/settings/users', VIEWS.WORKFLOWS, []],
['/settings/users', VIEWS.USERS_SETTINGS, ['user:create', 'user:update']],
diff --git a/packages/frontend/editor-ui/src/router.ts b/packages/frontend/editor-ui/src/app/router.ts
similarity index 98%
rename from packages/frontend/editor-ui/src/router.ts
rename to packages/frontend/editor-ui/src/app/router.ts
index 5ea543507e2..0a5b24b71f7 100644
--- a/packages/frontend/editor-ui/src/router.ts
+++ b/packages/frontend/editor-ui/src/app/router.ts
@@ -20,20 +20,21 @@ import {
import { useTelemetry } from '@/app/composables/useTelemetry';
import { middleware } from '@/app/utils/rbac/middleware';
import type { RouterMiddleware } from '@/app/types/router';
-import { initializeAuthenticatedFeatures, initializeCore } from '@/init';
+import { initializeAuthenticatedFeatures, initializeCore } from '@/app/init';
import { tryToParseNumber } from '@/app/utils/typesUtils';
import { projectsRoutes } from '@/features/collaboration/projects/projects.routes';
import { MfaRequiredError } from '@n8n/rest-api-client';
import { useCalloutHelpers } from '@/app/composables/useCalloutHelpers';
import { useRecentResources } from '@/features/shared/commandBar/composables/useRecentResources';
import { useEnvFeatureFlag } from '@/features/shared/envFeatureFlag/useEnvFeatureFlag';
-import { usePostHog } from './app/stores/posthog.store';
+import { usePostHog } from '@/app/stores/posthog.store';
const ChangePasswordView = async () =>
await import('@/features/core/auth/views/ChangePasswordView.vue');
const ErrorView = async () => await import('@/app/views/ErrorView.vue');
const EntityNotFound = async () => await import('@/app/views/EntityNotFound.vue');
const EntityUnAuthorised = async () => await import('@/app/views/EntityUnAuthorised.vue');
+const OAuthConsentView = async () => await import('@/app/views/OAuthConsentView.vue');
const ForgotMyPasswordView = async () =>
await import('@/features/core/auth/views/ForgotMyPasswordView.vue');
const MainHeader = async () => await import('@/app/components/MainHeader/MainHeader.vue');
@@ -342,12 +343,7 @@ export const routes: RouteRecordRaw[] = [
sidebar: MainSidebar,
},
meta: {
- middleware: ['authenticated', 'enterprise'],
- middlewareOptions: {
- enterprise: {
- feature: [EnterpriseEditionFeature.WorkflowHistory],
- },
- },
+ middleware: ['authenticated'],
},
},
{
@@ -472,6 +468,16 @@ export const routes: RouteRecordRaw[] = [
middleware: ['authenticated'],
},
},
+ {
+ path: '/oauth/consent',
+ name: VIEWS.OAUTH_CONSENT,
+ components: {
+ default: OAuthConsentView,
+ },
+ meta: {
+ middleware: ['authenticated'],
+ },
+ },
{
path: '/setup',
name: VIEWS.SETUP,
diff --git a/packages/frontend/editor-ui/src/app/stores/consent.store.ts b/packages/frontend/editor-ui/src/app/stores/consent.store.ts
new file mode 100644
index 00000000000..f83fb4761c0
--- /dev/null
+++ b/packages/frontend/editor-ui/src/app/stores/consent.store.ts
@@ -0,0 +1,60 @@
+import { STORES } from '@n8n/stores';
+import { defineStore } from 'pinia';
+import { useRootStore } from '@n8n/stores/useRootStore';
+
+import * as consentApi from '@n8n/rest-api-client/api/consent';
+import { ref } from 'vue';
+import type { ConsentDetails } from '@n8n/rest-api-client/api/consent';
+
+export const useConsentStore = defineStore(STORES.CONSENT, () => {
+ const consentDetails = ref(null);
+ const isLoading = ref(false);
+ const error = ref(null);
+
+ const rootStore = useRootStore();
+
+ const fetchConsentDetails = async () => {
+ isLoading.value = true;
+ error.value = null;
+
+ try {
+ consentDetails.value = await consentApi.getConsentDetails(rootStore.restApiContext);
+ return consentDetails.value;
+ } catch (err) {
+ error.value = err instanceof Error ? err.message : 'Failed to load consent details';
+ throw err;
+ } finally {
+ isLoading.value = false;
+ }
+ };
+
+ const approveConsent = async (approved: boolean) => {
+ isLoading.value = true;
+ error.value = null;
+
+ try {
+ const response = await consentApi.approveConsent(rootStore.restApiContext, approved);
+ return response;
+ } catch (err) {
+ error.value = err instanceof Error ? err.message : 'Failed to process consent';
+ throw err;
+ } finally {
+ isLoading.value = false;
+ }
+ };
+
+ const resetState = () => {
+ consentDetails.value = null;
+ isLoading.value = false;
+ error.value = null;
+ };
+
+ return {
+ fetchConsentDetails,
+ approveConsent,
+ resetState,
+ consentDetails,
+ isLoading,
+ error,
+ };
+});
diff --git a/packages/frontend/editor-ui/src/app/stores/workflows.store.ts b/packages/frontend/editor-ui/src/app/stores/workflows.store.ts
index ff7fb49391e..c38a5403995 100644
--- a/packages/frontend/editor-ui/src/app/stores/workflows.store.ts
+++ b/packages/frontend/editor-ui/src/app/stores/workflows.store.ts
@@ -96,6 +96,7 @@ import { getResourcePermissions } from '@n8n/permissions';
const defaults: Omit & { settings: NonNullable } = {
name: '',
+ description: '',
active: false,
isArchived: false,
createdAt: -1,
@@ -609,7 +610,7 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
pageSize = DEFAULT_WORKFLOW_PAGE_SIZE,
sortBy?: string,
filters: {
- name?: string;
+ query?: string;
tags?: string[];
active?: boolean;
isArchived?: boolean;
@@ -652,20 +653,20 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
async function searchWorkflows({
projectId,
- name,
+ query,
nodeTypes,
tags,
select,
}: {
projectId?: string;
- name?: string;
+ query?: string;
nodeTypes?: string[];
tags?: string[];
select?: string[];
}): Promise {
const filter = {
projectId,
- name,
+ query,
nodeTypes,
tags,
};
@@ -885,6 +886,10 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
workflow.value.isArchived = isArchived;
}
+ function setDescription(description: string | undefined | null) {
+ workflow.value.description = description;
+ }
+
async function getDuplicateCurrentWorkflowName(currentWorkflowName: string): Promise {
if (
currentWorkflowName &&
@@ -1611,6 +1616,47 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
return updated;
}
+ async function saveWorkflowDescription(
+ id: string,
+ description: string | null,
+ ): Promise {
+ let currentVersionId = '';
+ const isCurrentWorkflow = id === workflow.value.id;
+
+ if (isCurrentWorkflow) {
+ currentVersionId = workflow.value.versionId;
+ } else {
+ const cached = workflowsById.value[id];
+ if (cached?.versionId) {
+ currentVersionId = cached.versionId;
+ } else {
+ const fetched = await fetchWorkflow(id);
+ currentVersionId = fetched.versionId;
+ }
+ }
+
+ const updated = await updateWorkflow(id, {
+ versionId: currentVersionId,
+ description,
+ });
+
+ // Update local store state
+ if (isCurrentWorkflow) {
+ setDescription(updated.description ?? '');
+ if (updated.versionId !== currentVersionId) {
+ setWorkflowVersionId(updated.versionId);
+ }
+ } else if (workflowsById.value[id]) {
+ workflowsById.value[id] = {
+ ...workflowsById.value[id],
+ description: updated.description,
+ versionId: updated.versionId,
+ };
+ }
+
+ return updated;
+ }
+
async function runWorkflow(startRunData: IStartRunData): Promise {
if (startRunData.workflowData.settings === null) {
startRunData.workflowData.settings = undefined;
@@ -1859,6 +1905,7 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
setWorkflowInactive,
fetchActiveWorkflows,
setIsArchived,
+ setDescription,
getDuplicateCurrentWorkflowName,
setWorkflowExecutionRunData,
setWorkflowPinData,
@@ -1887,6 +1934,7 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
createNewWorkflow,
updateWorkflow,
updateWorkflowSetting,
+ saveWorkflowDescription,
runWorkflow,
removeTestWebhook,
fetchExecutionDataById,
diff --git a/packages/frontend/editor-ui/src/app/styles/_global.scss b/packages/frontend/editor-ui/src/app/styles/_global.scss
deleted file mode 100644
index 233a458c967..00000000000
--- a/packages/frontend/editor-ui/src/app/styles/_global.scss
+++ /dev/null
@@ -1,3 +0,0 @@
-:root {
- --navbar--height: 64px;
-}
diff --git a/packages/frontend/editor-ui/src/app/styles/_variables.scss b/packages/frontend/editor-ui/src/app/styles/_variables.scss
deleted file mode 100644
index 4676ec0b250..00000000000
--- a/packages/frontend/editor-ui/src/app/styles/_variables.scss
+++ /dev/null
@@ -1 +0,0 @@
-$ease-out-expo: cubic-bezier(0.19, 1, 0.22, 1);
diff --git a/packages/frontend/editor-ui/src/type-utils.d.ts b/packages/frontend/editor-ui/src/app/types/utils.ts
similarity index 100%
rename from packages/frontend/editor-ui/src/type-utils.d.ts
rename to packages/frontend/editor-ui/src/app/types/utils.ts
diff --git a/packages/frontend/editor-ui/src/app/views/OAuthConsentView.vue b/packages/frontend/editor-ui/src/app/views/OAuthConsentView.vue
new file mode 100644
index 00000000000..2e918d8b169
--- /dev/null
+++ b/packages/frontend/editor-ui/src/app/views/OAuthConsentView.vue
@@ -0,0 +1,264 @@
+
+
+
+
+
+
+
+
+
+ {{ i18n.baseText('oauth.consentView.success.title') }}
+
+
+ {{ i18n.baseText('oauth.consentView.success.description') }}
+
+
+
+
+
+ {{
+ i18n.baseText('oauth.consentView.heading', {
+ interpolate: { clientName: clentDetails?.clientName ?? '' },
+ })
+ }}
+
+
+
+ {{
+ i18n.baseText('oauth.consentView.description', {
+ interpolate: { clientName: clentDetails?.clientName ?? '' },
+ })
+ }}
+
+
+ - {{ i18n.baseText('oauth.consentView.action.listWorkflows') }}
+ - {{ i18n.baseText('oauth.consentView.action.workflowDetails') }}
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/frontend/editor-ui/src/app/views/SettingsView.test.ts b/packages/frontend/editor-ui/src/app/views/SettingsView.test.ts
index 9067a4097c8..34f5b6391ba 100644
--- a/packages/frontend/editor-ui/src/app/views/SettingsView.test.ts
+++ b/packages/frontend/editor-ui/src/app/views/SettingsView.test.ts
@@ -4,7 +4,7 @@ import userEvent from '@testing-library/user-event';
import { createComponentRenderer } from '@/__tests__/render';
import SettingsView from '@/app/views/SettingsView.vue';
import { VIEWS } from '@/app/constants';
-import { routes as originalRoutes } from '@/router';
+import { routes as originalRoutes } from '@/app/router';
const component = { template: '' };
const settingsRoute = originalRoutes.find((route) => route.name === VIEWS.SETTINGS);
diff --git a/packages/frontend/editor-ui/src/app/views/WorkflowsView.test.ts b/packages/frontend/editor-ui/src/app/views/WorkflowsView.test.ts
index 0e776e4f39b..6f68c4a0113 100644
--- a/packages/frontend/editor-ui/src/app/views/WorkflowsView.test.ts
+++ b/packages/frontend/editor-ui/src/app/views/WorkflowsView.test.ts
@@ -289,7 +289,7 @@ describe('WorkflowsView', () => {
expect.any(Number),
expect.any(String),
expect.objectContaining({
- name: 'one',
+ query: 'one',
isArchived: false,
}),
expect.any(Boolean),
diff --git a/packages/frontend/editor-ui/src/app/views/WorkflowsView.vue b/packages/frontend/editor-ui/src/app/views/WorkflowsView.vue
index 038f41f0a5c..917d2d9dbdb 100644
--- a/packages/frontend/editor-ui/src/app/views/WorkflowsView.vue
+++ b/packages/frontend/editor-ui/src/app/views/WorkflowsView.vue
@@ -255,6 +255,10 @@ const teamProjectsEnabled = computed(() => {
return projectsStore.isTeamProjectFeatureEnabled;
});
+const mcpEnabled = computed(() => {
+ return settingsStore.isModuleActive('mcp') && settingsStore.moduleSettings.mcp?.mcpAccessEnabled;
+});
+
const showFolders = computed(() => {
return foldersEnabled.value && !projectPages.isOverviewSubPage && !projectPages.isSharedSubPage;
});
@@ -348,6 +352,7 @@ const workflowListResources = computed(() => {
resourceType: 'workflow',
id: resource.id,
name: resource.name,
+ description: resource.description,
active: resource.active ?? false,
isArchived: resource.isArchived,
updatedAt: resource.updatedAt.toString(),
@@ -672,7 +677,7 @@ const fetchWorkflows = async () => {
pageSize.value,
currentSort.value,
{
- name: filters.value.search || undefined,
+ query: filters.value.search || undefined,
active: activeFilter,
isArchived: archivedFilter,
tags: tags.length ? tags : undefined,
@@ -2110,7 +2115,7 @@ const onNameSubmit = async (name: string) => {
:show-ownership-badge="showCardsBadge"
:are-folders-enabled="settingsStore.isFoldersFeatureEnabled"
:are-tags-enabled="settingsStore.areTagsEnabled"
- :is-mcp-enabled="settingsStore.isModuleActive('mcp')"
+ :is-mcp-enabled="mcpEnabled"
@click:tag="onClickTag"
@workflow:deleted="refreshWorkflows"
@workflow:archived="refreshWorkflows"
diff --git a/packages/frontend/editor-ui/src/features/ai/assistant/composables/useAIAssistantHelpers.ts b/packages/frontend/editor-ui/src/features/ai/assistant/composables/useAIAssistantHelpers.ts
index 534108c62d9..07fd7bb83cf 100644
--- a/packages/frontend/editor-ui/src/features/ai/assistant/composables/useAIAssistantHelpers.ts
+++ b/packages/frontend/editor-ui/src/features/ai/assistant/composables/useAIAssistantHelpers.ts
@@ -195,6 +195,7 @@ export const useAIAssistantHelpers = () => {
const schema = getSchemaForExecutionData(
executionDataToJson(getInputDataWithPinned(node)),
excludeValues,
+ true, // collapseArrays: true for AI assistant to avoid verbose array expansion
);
schemas.push({
nodeName: node.name,
diff --git a/packages/frontend/editor-ui/src/features/ai/chatHub/chat.store.ts b/packages/frontend/editor-ui/src/features/ai/chatHub/chat.store.ts
index 29b01ae47ce..d9792e28e49 100644
--- a/packages/frontend/editor-ui/src/features/ai/chatHub/chat.store.ts
+++ b/packages/frontend/editor-ui/src/features/ai/chatHub/chat.store.ts
@@ -45,9 +45,11 @@ import type {
import { retry } from '@n8n/utils/retry';
import { isMatchedAgent } from './chat.utils';
import { createAiMessageFromStreamingState, flattenModel } from './chat.utils';
+import { useTelemetry } from '@/app/composables/useTelemetry';
export const useChatStore = defineStore(CHAT_STORE, () => {
const rootStore = useRootStore();
+ const telemetry = useTelemetry();
const agents = ref();
const sessions = ref();
const currentEditingAgent = ref(null);
@@ -470,6 +472,12 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
onStreamDone,
onStreamError,
);
+
+ telemetry.track('User sent chat hub message', {
+ ...flattenModel(model),
+ is_custom: model.provider === 'custom-agent',
+ chat_session_id: sessionId,
+ });
}
function editMessage(
@@ -638,6 +646,8 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
await fetchAgents(credentials);
+ telemetry.track('User created agent', { ...flattenModel(payload) });
+
return agentModel;
}
diff --git a/packages/frontend/editor-ui/src/features/ai/chatHub/components/ChatSidebarContent.vue b/packages/frontend/editor-ui/src/features/ai/chatHub/components/ChatSidebarContent.vue
index 8d6847fb654..747642b3e0c 100644
--- a/packages/frontend/editor-ui/src/features/ai/chatHub/components/ChatSidebarContent.vue
+++ b/packages/frontend/editor-ui/src/features/ai/chatHub/components/ChatSidebarContent.vue
@@ -14,6 +14,7 @@ import Logo from '@n8n/design-system/components/N8nLogo';
import { computed, onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import ChatSessionMenuItem from './ChatSessionMenuItem.vue';
+import { useTelemetry } from '@/app/composables/useTelemetry';
defineProps<{ isMobileDevice: boolean }>();
@@ -24,6 +25,7 @@ const toast = useToast();
const message = useMessage();
const sidebar = useChatHubSidebarState();
const settingsStore = useSettingsStore();
+const telemetry = useTelemetry();
const renamingSessionId = ref();
@@ -77,6 +79,11 @@ async function handleDeleteSession(sessionId: string) {
}
}
+function handleNewChatClick() {
+ telemetry.track('User clicked new chat button', {});
+ sidebar.toggleOpen(false);
+}
+
onMounted(() => {
void chatStore.fetchSessions();
});
@@ -113,7 +120,7 @@ onMounted(() => {
label="New Chat"
icon="square-pen"
:active="route.name === CHAT_VIEW"
- @click="sidebar.toggleOpen(false)"
+ @click="handleNewChatClick"
/>
(null);
const uiStore = useUIStore();
const credentialsStore = useCredentialsStore();
+const telemetry = useTelemetry();
const credentialsName = computed(() =>
selectedAgent
@@ -179,11 +182,25 @@ function onSelect(id: string) {
return;
}
+ telemetry.track('User selected model or agent', {
+ ...flattenModel(selected.model),
+ is_custom: selected.model.provider === 'custom-agent',
+ });
+
emit('change', selected);
}
function handleCreateNewCredential(provider: ChatHubLLMProvider) {
- uiStore.openNewCredential(PROVIDER_CREDENTIAL_TYPE_MAP[provider]);
+ const credentialType = PROVIDER_CREDENTIAL_TYPE_MAP[provider];
+
+ telemetry.track('User opened Credential modal', {
+ credential_type: credentialType,
+ source: 'chat',
+ new_credential: true,
+ workflow_id: null,
+ });
+
+ uiStore.openNewCredential(credentialType);
}
onClickOutside(
diff --git a/packages/frontend/editor-ui/src/features/ai/mcpAccess/SettingsMCPView.test.ts b/packages/frontend/editor-ui/src/features/ai/mcpAccess/SettingsMCPView.test.ts
index ec47da3beb1..5d45fa56df1 100644
--- a/packages/frontend/editor-ui/src/features/ai/mcpAccess/SettingsMCPView.test.ts
+++ b/packages/frontend/editor-ui/src/features/ai/mcpAccess/SettingsMCPView.test.ts
@@ -7,7 +7,6 @@ import { useRootStore } from '@n8n/stores/useRootStore';
import { useUsersStore } from '@/features/settings/users/users.store';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import { useMCPStore } from './mcp.store';
-import { MCP_WORKFLOWS } from './SettingsMCPView.test.constants';
vi.mock('@/app/composables/useDocumentTitle', () => ({
useDocumentTitle: () => ({
@@ -85,54 +84,4 @@ describe('SettingsMCPView', () => {
expect(toggle).toHaveClass('is-disabled');
});
-
- test('shows an empty state when no workflows are available', async () => {
- mcpStore.mcpAccessEnabled = true;
-
- const { findByTestId } = renderComponent({ pinia });
- await waitAllPromises();
-
- expect(await findByTestId('empty-workflow-list-box')).toBeInTheDocument();
- });
-
- test('shows workflows table when there are available workflows', async () => {
- mcpStore.fetchWorkflowsAvailableForMCP.mockResolvedValue(MCP_WORKFLOWS);
- mcpStore.mcpAccessEnabled = true;
-
- const { getByTestId } = renderComponent({ pinia });
- await waitAllPromises();
-
- expect(getByTestId('mcp-workflow-list')).toBeInTheDocument();
- expect(getByTestId('mcp-workflow-table')).toBeInTheDocument();
-
- // Should render both workflow info correctly
- const rows = getByTestId('mcp-workflow-table').querySelectorAll('table tbody tr');
-
- expect(rows).toHaveLength(MCP_WORKFLOWS.length);
-
- rows.forEach((row, index) => {
- const workflow = MCP_WORKFLOWS[index];
-
- expect(row.querySelector('[data-test-id=mcp-workflow-name]')).toHaveTextContent(
- workflow.name,
- );
-
- if (workflow.parentFolder) {
- expect(row.querySelector('[data-test-id=mcp-workflow-folder-link]')).toBeInTheDocument();
- expect(row.querySelector('[data-test-id=mcp-workflow-folder-name]')).toHaveTextContent(
- workflow.parentFolder.name,
- );
- } else {
- expect(row.querySelector('[data-test-id=mcp-workflow-no-folder]')).toBeInTheDocument();
- }
-
- const projectNameCell = row.querySelector('[data-test-id=mcp-workflow-project-name]');
-
- if (workflow.homeProject?.type === 'personal') {
- expect(projectNameCell).toHaveTextContent('Personal');
- } else {
- expect(projectNameCell).toHaveTextContent(workflow.homeProject?.name || '');
- }
- });
- });
});
diff --git a/packages/frontend/editor-ui/src/features/ai/mcpAccess/SettingsMCPView.vue b/packages/frontend/editor-ui/src/features/ai/mcpAccess/SettingsMCPView.vue
index 05fa519f21c..4ebcb5438e8 100644
--- a/packages/frontend/editor-ui/src/features/ai/mcpAccess/SettingsMCPView.vue
+++ b/packages/frontend/editor-ui/src/features/ai/mcpAccess/SettingsMCPView.vue
@@ -1,34 +1,21 @@
-
- {{ i18n.baseText('settings.mcp') }}
-
-