mirror of
https://github.com/n8n-io/n8n.git
synced 2026-06-01 09:17:08 +02:00
feat(editor): Add workflow execution to AI Workflow Builder (no-changelog) (#20037)
Co-authored-by: Giulio Andreini <g.andreini@gmail.com>
This commit is contained in:
parent
a5c1adf6ea
commit
a71306e2d0
|
|
@ -362,24 +362,42 @@ update_node_parameters({{
|
|||
|
||||
const responsePatterns = `
|
||||
<response_patterns>
|
||||
IMPORTANT: Only provide ONE response AFTER all tool execution is complete.
|
||||
IMPORTANT: Only provide ONE response AFTER all tool executions are complete.
|
||||
|
||||
Response format:
|
||||
EXCEPTION - Error handling:
|
||||
When tool execution fails, provide a brief acknowledgment before attempting fixes:
|
||||
- "The workflow hit an error. Let me debug this."
|
||||
- "Execution failed. Let me trace the issue."
|
||||
- "Got a workflow error. Investigating now."
|
||||
- Or similar brief phrases
|
||||
Then proceed with debugging/fixing without additional commentary.
|
||||
|
||||
Response format conditions:
|
||||
- Include "**⚙️ How to Setup**" section ONLY if this is the initial workflow creation
|
||||
- Include "**📝 What's changed**" section ONLY for non-initial modifications (skip for first workflow creation)
|
||||
- Skip setup section for minor tweaks, bug fixes, or cosmetic changes
|
||||
|
||||
When changes section is included:
|
||||
**📝 What's changed**
|
||||
- Brief bullets highlighting key modifications made
|
||||
- Focus on functional changes, not technical implementation details
|
||||
|
||||
When setup section is included:
|
||||
**⚙️ How to Setup** (numbered format)
|
||||
- List credentials and parameters that need to configured
|
||||
- Only list incomplete tasks that need user action (skip what's already configured)
|
||||
- List only parameter placeholders requiring user configuration
|
||||
- Include only incomplete tasks needing user action (skip pre-configured items)
|
||||
- IMPORTANT: NEVER instruct user to set-up authentication or credentials for nodes - this will be handled in the UI
|
||||
- IMPORTANT: Focus on workflow-specific parameters/placeholders only
|
||||
|
||||
**ℹ️ How to Use**
|
||||
- Only essential user actions (what to click, where to go)
|
||||
|
||||
End with: "Let me know if you'd like to adjust anything."
|
||||
Always end with: "Let me know if you'd like to adjust anything."
|
||||
|
||||
ABSOLUTELY FORBIDDEN IN BUILDING MODE:
|
||||
- Any text between tool calls
|
||||
- Any text between tool calls (except error acknowledgments)
|
||||
- Progress updates during execution
|
||||
- "Perfect!", "Now let me...", "Excellent!"
|
||||
- Describing what was built
|
||||
- Explaining workflow functionality
|
||||
- Celebratory phrases ("Perfect!", "Now let me...", "Excellent!", "Great!")
|
||||
- Describing what was built or explaining functionality
|
||||
- Workflow narration or step-by-step commentary
|
||||
- Status updates while tools are running
|
||||
</response_patterns>
|
||||
`;
|
||||
|
||||
|
|
|
|||
|
|
@ -377,6 +377,7 @@ defineExpose({
|
|||
</div>
|
||||
</div>
|
||||
</data>
|
||||
<slot name="messagesFooter" />
|
||||
</div>
|
||||
<div
|
||||
v-if="loadingMessage"
|
||||
|
|
|
|||
|
|
@ -253,6 +253,8 @@ exports[`AskAssistantChat > renders chat with messages correctly 1`] = `
|
|||
</div>
|
||||
</data>
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
<!--v-if-->
|
||||
</div>
|
||||
|
|
@ -445,6 +447,8 @@ exports[`AskAssistantChat > renders end of session chat correctly 1`] = `
|
|||
<!--v-if-->
|
||||
</data>
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
<!--v-if-->
|
||||
</div>
|
||||
|
|
@ -530,6 +534,8 @@ exports[`AskAssistantChat > renders error message correctly with retry button 1`
|
|||
<!--v-if-->
|
||||
</data>
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
<!--v-if-->
|
||||
</div>
|
||||
|
|
@ -615,6 +621,8 @@ exports[`AskAssistantChat > renders message with code snippet 1`] = `
|
|||
<!--v-if-->
|
||||
</data>
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
<!--v-if-->
|
||||
</div>
|
||||
|
|
@ -700,6 +708,8 @@ exports[`AskAssistantChat > renders streaming chat correctly 1`] = `
|
|||
<!--v-if-->
|
||||
</data>
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
<!--v-if-->
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -217,6 +217,16 @@
|
|||
"aiAssistant.builder.canvasPrompt.startManually.title": "Start manually",
|
||||
"aiAssistant.builder.canvasPrompt.startManually.subTitle": "Add the first node",
|
||||
"aiAssistant.builder.streamAbortedMessage": "[Task aborted]",
|
||||
"aiAssistant.builder.executeMessage.description": "Complete these steps before executing your workflow:",
|
||||
"aiAssistant.builder.executeMessage.noIssues": "Your workflow is ready to be executed",
|
||||
"aiAssistant.builder.executeMessage.validationTooltip": "Complete the steps above before executing",
|
||||
"aiAssistant.builder.executeMessage.execute": "Execute and refine",
|
||||
"aiAssistant.builder.executeMessage.noExecutionData": "Workflow execution could not be started. Please try again.",
|
||||
"aiAssistant.builder.executeMessage.executionSuccess": "Workflow executed successfully.",
|
||||
"aiAssistant.builder.executeMessage.executionFailedOnNode": "Workflow execution failed on node \"{nodeName}\": {errorMessage}",
|
||||
"aiAssistant.builder.executeMessage.executionFailed": "Workflow execution failed: {errorMessage}",
|
||||
"aiAssistant.builder.toast.title": "Send chat message to start the execution",
|
||||
"aiAssistant.builder.toast.description": "Please send a message in the chat panel to start the execution of your workflow",
|
||||
"aiAssistant.assistant": "AI Assistant",
|
||||
"aiAssistant.newSessionModal.title.part1": "Start new",
|
||||
"aiAssistant.newSessionModal.title.part2": "session",
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { useWorkflowSaving } from '@/composables/useWorkflowSaving';
|
|||
import type { RatingFeedback } from '@n8n/design-system/types/assistant';
|
||||
import { isWorkflowUpdatedMessage } from '@n8n/design-system/types/assistant';
|
||||
import { nodeViewEventBus } from '@/event-bus';
|
||||
import ExecuteMessage from './ExecuteMessage.vue';
|
||||
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
|
@ -30,7 +31,6 @@ const { goToUpgrade } = usePageRedirectionHelper();
|
|||
// Track processed workflow updates
|
||||
const processedWorkflowUpdates = ref(new Set<string>());
|
||||
const trackedTools = ref(new Set<string>());
|
||||
const assistantChatRef = ref<InstanceType<typeof AskAssistantChat> | null>(null);
|
||||
const workflowUpdated = ref<{ start: string; end: string } | undefined>();
|
||||
|
||||
const user = computed(() => ({
|
||||
|
|
@ -40,9 +40,19 @@ const user = computed(() => ({
|
|||
|
||||
const loadingMessage = computed(() => builderStore.assistantThinkingMessage);
|
||||
const currentRoute = computed(() => route.name);
|
||||
const showExecuteMessage = computed(() => {
|
||||
const builderUpdatedWorkflowMessageIndex = builderStore.chatMessages.findLastIndex(
|
||||
(msg) => msg.type === 'workflow-updated',
|
||||
);
|
||||
return (
|
||||
!builderStore.streaming &&
|
||||
workflowsStore.workflow.nodes.length > 0 &&
|
||||
builderUpdatedWorkflowMessageIndex > -1
|
||||
);
|
||||
});
|
||||
const creditsQuota = computed(() => builderStore.creditsQuota);
|
||||
const creditsRemaining = computed(() => builderStore.creditsRemaining);
|
||||
const showAskOwnerTooltip = computed(() => usersStore.isInstanceOwner !== false);
|
||||
const showAskOwnerTooltip = computed(() => usersStore.isInstanceOwner);
|
||||
|
||||
async function onUserMessage(content: string) {
|
||||
const isNewWorkflow = workflowsStore.isNewWorkflow;
|
||||
|
|
@ -109,6 +119,56 @@ function trackWorkflowModifications() {
|
|||
}
|
||||
}
|
||||
|
||||
function onWorkflowExecuted() {
|
||||
const executionData = workflowsStore.workflowExecutionData;
|
||||
const executionStatus = executionData?.status ?? 'unknown';
|
||||
const errorNodeName = executionData?.data?.resultData.lastNodeExecuted;
|
||||
const errorNodeType = errorNodeName
|
||||
? workflowsStore.workflow.nodes.find((node) => node.name === errorNodeName)?.type
|
||||
: undefined;
|
||||
|
||||
if (!executionData) {
|
||||
builderStore.sendChatMessage({
|
||||
text: i18n.baseText('aiAssistant.builder.executeMessage.noExecutionData'),
|
||||
type: 'execution',
|
||||
executionStatus: 'error',
|
||||
errorMessage: 'Workflow execution data missing after run attempt.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (executionStatus === 'success') {
|
||||
builderStore.sendChatMessage({
|
||||
text: i18n.baseText('aiAssistant.builder.executeMessage.executionSuccess'),
|
||||
type: 'execution',
|
||||
executionStatus,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const executionError = executionData.data?.resultData.error?.message ?? 'Unknown error';
|
||||
const scopedErrorMessage = errorNodeName
|
||||
? i18n.baseText('aiAssistant.builder.executeMessage.executionFailedOnNode', {
|
||||
interpolate: {
|
||||
nodeName: errorNodeName,
|
||||
errorMessage: executionError,
|
||||
},
|
||||
})
|
||||
: i18n.baseText('aiAssistant.builder.executeMessage.executionFailed', {
|
||||
interpolate: { errorMessage: executionError },
|
||||
});
|
||||
|
||||
const failureStatus = executionStatus === 'unknown' ? 'error' : executionStatus;
|
||||
|
||||
builderStore.sendChatMessage({
|
||||
text: scopedErrorMessage,
|
||||
type: 'execution',
|
||||
errorMessage: executionError,
|
||||
errorNodeType,
|
||||
executionStatus: failureStatus,
|
||||
});
|
||||
}
|
||||
|
||||
// Watch for workflow updates and apply them
|
||||
watch(
|
||||
() => builderStore.workflowMessages,
|
||||
|
|
@ -186,7 +246,6 @@ watch(currentRoute, () => {
|
|||
<template>
|
||||
<div data-test-id="ask-assistant-chat" tabindex="0" :class="$style.container" @keydown.stop>
|
||||
<AskAssistantChat
|
||||
ref="assistantChatRef"
|
||||
:user="user"
|
||||
:messages="builderStore.chatMessages"
|
||||
:streaming="builderStore.streaming"
|
||||
|
|
@ -198,7 +257,7 @@ watch(currentRoute, () => {
|
|||
:credits-quota="creditsQuota"
|
||||
:credits-remaining="creditsRemaining"
|
||||
:show-ask-owner-tooltip="showAskOwnerTooltip"
|
||||
:inputPlaceholder="i18n.baseText('aiAssistant.builder.assistantPlaceholder')"
|
||||
:input-placeholder="i18n.baseText('aiAssistant.builder.assistantPlaceholder')"
|
||||
@close="emit('close')"
|
||||
@message="onUserMessage"
|
||||
@upgrade-click="() => goToUpgrade('ai-builder-sidebar', 'upgrade-builder')"
|
||||
|
|
@ -208,6 +267,9 @@ watch(currentRoute, () => {
|
|||
<template #header>
|
||||
<slot name="header" />
|
||||
</template>
|
||||
<template #messagesFooter>
|
||||
<ExecuteMessage v-if="showExecuteMessage" @workflow-executed="onWorkflowExecuted" />
|
||||
</template>
|
||||
<template #placeholder>
|
||||
<N8nText :class="$style.topText">{{
|
||||
i18n.baseText('aiAssistant.builder.assistantPlaceholder')
|
||||
|
|
|
|||
|
|
@ -0,0 +1,245 @@
|
|||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { reactive, ref, nextTick } from 'vue';
|
||||
import { fireEvent } from '@testing-library/vue';
|
||||
import { flushPromises } from '@vue/test-utils';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { setActivePinia } from 'pinia';
|
||||
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import { mockedStore } from '@/__tests__/utils';
|
||||
import type { INodeUi } from '@/Interface';
|
||||
import ExecuteMessage from './ExecuteMessage.vue';
|
||||
import { CHAT_TRIGGER_NODE_TYPE } from '@/constants';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||
import { useLogsStore } from '@/stores/logs.store';
|
||||
|
||||
const workflowValidationIssuesRef = ref<
|
||||
Array<{ node: string; type: string; value: string | string[] }>
|
||||
>([]);
|
||||
const isWorkflowRunningRef = ref(false);
|
||||
const executionWaitingForWebhookRef = ref(false);
|
||||
const selectedTriggerNodeNameRef = ref<string | undefined>(undefined);
|
||||
const workflowNodes = reactive<INodeUi[]>([
|
||||
{
|
||||
id: '1',
|
||||
name: 'Start Trigger',
|
||||
type: 'n8n-nodes-base.manualTrigger',
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
typeVersion: 1,
|
||||
issues: {},
|
||||
},
|
||||
]);
|
||||
|
||||
const showMessageMock = vi.fn();
|
||||
const runWorkflowMock = vi.fn();
|
||||
|
||||
vi.mock('@n8n/i18n', async (importOriginal) => ({
|
||||
...(await importOriginal()),
|
||||
useI18n: () => ({
|
||||
baseText: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRouter: () => ({
|
||||
push: vi.fn(),
|
||||
}),
|
||||
useRoute: () => ({ params: {} }),
|
||||
RouterLink: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/composables/useRunWorkflow', () => ({
|
||||
useRunWorkflow: () => ({
|
||||
runWorkflow: runWorkflowMock,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/composables/useToast', () => ({
|
||||
useToast: () => ({
|
||||
showMessage: showMessageMock,
|
||||
}),
|
||||
}));
|
||||
|
||||
const renderComponent = createComponentRenderer(ExecuteMessage);
|
||||
|
||||
vi.mock('./NodeIssueItem.vue', () => ({
|
||||
default: {
|
||||
template: '<li />',
|
||||
},
|
||||
}));
|
||||
|
||||
describe('ExecuteMessage', () => {
|
||||
let workflowsStore: ReturnType<typeof mockedStore<typeof useWorkflowsStore>>;
|
||||
let nodeTypesStore: ReturnType<typeof mockedStore<typeof useNodeTypesStore>>;
|
||||
let logsStore: ReturnType<typeof mockedStore<typeof useLogsStore>>;
|
||||
let renderExecuteMessage: () => ReturnType<ReturnType<typeof createComponentRenderer>>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
runWorkflowMock.mockReset();
|
||||
showMessageMock.mockReset();
|
||||
workflowValidationIssuesRef.value = [];
|
||||
isWorkflowRunningRef.value = false;
|
||||
executionWaitingForWebhookRef.value = false;
|
||||
selectedTriggerNodeNameRef.value = undefined;
|
||||
workflowNodes.splice(0, workflowNodes.length, {
|
||||
id: '1',
|
||||
name: 'Start Trigger',
|
||||
type: 'n8n-nodes-base.manualTrigger',
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
typeVersion: 1,
|
||||
issues: {},
|
||||
});
|
||||
|
||||
const pinia = createTestingPinia({ stubActions: false });
|
||||
setActivePinia(pinia);
|
||||
|
||||
workflowsStore = mockedStore(useWorkflowsStore);
|
||||
nodeTypesStore = mockedStore(useNodeTypesStore);
|
||||
logsStore = mockedStore(useLogsStore);
|
||||
|
||||
workflowsStore.workflow.nodes = workflowNodes as unknown as INodeUi[];
|
||||
workflowsStore.workflow.connections = {} as never;
|
||||
Object.defineProperty(workflowsStore, 'workflowValidationIssues', {
|
||||
get: () => workflowValidationIssuesRef.value,
|
||||
});
|
||||
workflowsStore.formatIssueMessage = vi.fn((value: string | string[]) =>
|
||||
Array.isArray(value) ? value.join(', ') : String(value),
|
||||
);
|
||||
Object.defineProperty(workflowsStore, 'isWorkflowRunning', {
|
||||
get: () => isWorkflowRunningRef.value,
|
||||
});
|
||||
Object.defineProperty(workflowsStore, 'executionWaitingForWebhook', {
|
||||
get: () => executionWaitingForWebhookRef.value,
|
||||
set: (value: boolean) => {
|
||||
executionWaitingForWebhookRef.value = value;
|
||||
},
|
||||
});
|
||||
Object.defineProperty(workflowsStore, 'selectedTriggerNodeName', {
|
||||
get: () => selectedTriggerNodeNameRef.value,
|
||||
});
|
||||
workflowsStore.setSelectedTriggerNodeName = vi.fn((name: string | undefined) => {
|
||||
selectedTriggerNodeNameRef.value = name;
|
||||
});
|
||||
workflowsStore.getNodeByName = vi.fn(
|
||||
(name: string) => workflowNodes.find((node) => node.name === name) ?? null,
|
||||
);
|
||||
logsStore.toggleOpen = vi.fn();
|
||||
nodeTypesStore.isTriggerNode = vi
|
||||
.fn()
|
||||
.mockImplementation((type: string) => type.toLowerCase().includes('trigger'));
|
||||
|
||||
renderExecuteMessage = () => renderComponent({ pinia });
|
||||
});
|
||||
|
||||
it('disables execution when validation issues exist', () => {
|
||||
workflowValidationIssuesRef.value = [
|
||||
{ node: 'Start Trigger', type: 'parameters', value: 'Missing field' },
|
||||
];
|
||||
|
||||
const { getAllByTestId, getByText } = renderExecuteMessage();
|
||||
|
||||
expect(getByText('aiAssistant.builder.executeMessage.description')).toBeInTheDocument();
|
||||
const button = getAllByTestId('execute-workflow-button')[0] as HTMLButtonElement;
|
||||
expect(button.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('runs workflow and emits completion event when execution finishes', async () => {
|
||||
runWorkflowMock.mockImplementation(async () => {
|
||||
isWorkflowRunningRef.value = true;
|
||||
});
|
||||
|
||||
const { getAllByTestId, emitted } = renderExecuteMessage();
|
||||
const button = getAllByTestId('execute-workflow-button')[0];
|
||||
expect(button).not.toHaveAttribute('disabled');
|
||||
|
||||
await fireEvent.click(button);
|
||||
await flushPromises();
|
||||
isWorkflowRunningRef.value = false;
|
||||
await nextTick();
|
||||
|
||||
expect(runWorkflowMock).toHaveBeenCalledWith({ triggerNode: 'Start Trigger' });
|
||||
expect(emitted().workflowExecuted).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('opens chat logs and shows info toast for chat trigger nodes', async () => {
|
||||
workflowNodes.push({
|
||||
id: '2',
|
||||
name: 'Chat Trigger',
|
||||
type: CHAT_TRIGGER_NODE_TYPE,
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
typeVersion: 1,
|
||||
issues: {},
|
||||
});
|
||||
selectedTriggerNodeNameRef.value = 'Chat Trigger';
|
||||
|
||||
const { getAllByTestId, emitted } = renderExecuteMessage();
|
||||
const button = getAllByTestId('execute-workflow-button')[0];
|
||||
|
||||
await fireEvent.click(button);
|
||||
|
||||
expect(runWorkflowMock).not.toHaveBeenCalled();
|
||||
expect(showMessageMock).toHaveBeenCalledWith({
|
||||
title: 'aiAssistant.builder.toast.title',
|
||||
message: 'aiAssistant.builder.toast.description',
|
||||
type: 'info',
|
||||
});
|
||||
expect(logsStore.toggleOpen).toHaveBeenCalledWith(true);
|
||||
expect(emitted().workflowExecuted).toBeUndefined();
|
||||
|
||||
isWorkflowRunningRef.value = true;
|
||||
await nextTick();
|
||||
isWorkflowRunningRef.value = false;
|
||||
await nextTick();
|
||||
|
||||
expect(emitted().workflowExecuted).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('emits completion after multiple run state toggles', async () => {
|
||||
runWorkflowMock.mockImplementation(async () => {
|
||||
isWorkflowRunningRef.value = true;
|
||||
await nextTick();
|
||||
isWorkflowRunningRef.value = false;
|
||||
});
|
||||
|
||||
const { getAllByTestId, emitted } = renderExecuteMessage();
|
||||
const button = getAllByTestId('execute-workflow-button')[0];
|
||||
|
||||
await fireEvent.click(button);
|
||||
await flushPromises();
|
||||
await nextTick();
|
||||
|
||||
// Toggle again manually to ensure watcher was cleaned up
|
||||
isWorkflowRunningRef.value = false;
|
||||
await nextTick();
|
||||
|
||||
expect(emitted().workflowExecuted).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('supports consecutive manual executions', async () => {
|
||||
runWorkflowMock.mockImplementation(async () => {
|
||||
isWorkflowRunningRef.value = true;
|
||||
await nextTick();
|
||||
isWorkflowRunningRef.value = false;
|
||||
await nextTick();
|
||||
});
|
||||
|
||||
const { getAllByTestId, emitted } = renderExecuteMessage();
|
||||
const button = getAllByTestId('execute-workflow-button')[0];
|
||||
|
||||
await fireEvent.click(button);
|
||||
await flushPromises();
|
||||
await nextTick();
|
||||
|
||||
await fireEvent.click(button);
|
||||
await flushPromises();
|
||||
await nextTick();
|
||||
|
||||
expect(runWorkflowMock).toHaveBeenCalledTimes(2);
|
||||
expect(emitted().workflowExecuted).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,260 @@
|
|||
<!-- eslint-disable import-x/extensions -->
|
||||
<script setup lang="ts">
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||
|
||||
import { useRunWorkflow } from '@/composables/useRunWorkflow';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch, type WatchStopHandle } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import NodeIssueItem from './NodeIssueItem.vue';
|
||||
import CanvasRunWorkflowButton from '@/components/canvas/elements/buttons/CanvasRunWorkflowButton.vue';
|
||||
import { useLogsStore } from '@/stores/logs.store';
|
||||
import { isChatNode } from '@/utils/aiUtils';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
import { N8nTooltip } from '@n8n/design-system';
|
||||
import { nextTick } from 'vue';
|
||||
|
||||
interface Emits {
|
||||
/** Emitted when workflow execution completes */
|
||||
workflowExecuted: [];
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
// Initialize composables and stores
|
||||
const router = useRouter();
|
||||
const workflowsStore = useWorkflowsStore();
|
||||
const nodeTypesStore = useNodeTypesStore();
|
||||
const i18n = useI18n();
|
||||
const logsStore = useLogsStore();
|
||||
const toast = useToast();
|
||||
|
||||
// Workflow execution composable
|
||||
const { runWorkflow } = useRunWorkflow({ router });
|
||||
|
||||
let executionWatcherStop: WatchStopHandle | undefined;
|
||||
|
||||
const containerRef = ref<HTMLElement>();
|
||||
|
||||
const stopExecutionWatcher = () => {
|
||||
if (executionWatcherStop) {
|
||||
executionWatcherStop();
|
||||
executionWatcherStop = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Sets up a watcher that fires exactly once per execution cycle.
|
||||
*/
|
||||
const ensureExecutionWatcher = () => {
|
||||
if (executionWatcherStop) return;
|
||||
|
||||
let wasRunning = workflowsStore.isWorkflowRunning;
|
||||
|
||||
executionWatcherStop = watch(
|
||||
() => workflowsStore.isWorkflowRunning,
|
||||
(isRunning) => {
|
||||
if (wasRunning && !isRunning) {
|
||||
stopExecutionWatcher();
|
||||
const wasCancelled = workflowsStore.workflowExecutionData?.status === 'canceled';
|
||||
|
||||
if (!wasCancelled) {
|
||||
emit('workflowExecuted');
|
||||
}
|
||||
}
|
||||
wasRunning = isRunning;
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
// Workflow validation from store
|
||||
const workflowIssues = computed(() =>
|
||||
workflowsStore.workflowValidationIssues.filter((issue) =>
|
||||
['credentials', 'parameters'].includes(issue.type),
|
||||
),
|
||||
);
|
||||
const hasValidationIssues = computed(() => workflowIssues.value.length > 0);
|
||||
const formatIssueMessage = workflowsStore.formatIssueMessage;
|
||||
|
||||
const triggerNodes = computed(() =>
|
||||
workflowsStore.workflow.nodes.filter((node) => nodeTypesStore.isTriggerNode(node.type)),
|
||||
);
|
||||
|
||||
// Helper to get node type
|
||||
function getNodeTypeByName(nodeName: string) {
|
||||
const node = workflowsStore.workflow.nodes.find((n) => n.name === nodeName);
|
||||
|
||||
if (!node) return null;
|
||||
return nodeTypesStore.getNodeType(node.type);
|
||||
}
|
||||
|
||||
// Reactive workflow state
|
||||
const isWorkflowRunning = computed(() => workflowsStore.isWorkflowRunning);
|
||||
const isExecutionWaitingForWebhook = computed(() => workflowsStore.executionWaitingForWebhook);
|
||||
/**
|
||||
* Determines available trigger nodes for execution
|
||||
* Excludes trigger nodes when there are validation issues to prevent dropdown rendering
|
||||
*/
|
||||
const availableTriggerNodes = computed(() => (hasValidationIssues.value ? [] : triggerNodes.value));
|
||||
const executeButtonTooltip = computed(() =>
|
||||
hasValidationIssues.value
|
||||
? i18n.baseText('aiAssistant.builder.executeMessage.validationTooltip')
|
||||
: '',
|
||||
);
|
||||
|
||||
async function onExecute() {
|
||||
if (hasValidationIssues.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
ensureExecutionWatcher();
|
||||
|
||||
const selectedTriggerNode =
|
||||
workflowsStore.selectedTriggerNodeName ?? availableTriggerNodes.value[0]?.name;
|
||||
const selectedTriggerNodeType = selectedTriggerNode
|
||||
? workflowsStore.getNodeByName(selectedTriggerNode)
|
||||
: null;
|
||||
|
||||
// If the selected trigger is a chat node, open logs panel instead of executing
|
||||
// the execution will be handled by the chat node itself
|
||||
if (selectedTriggerNodeType && isChatNode(selectedTriggerNodeType)) {
|
||||
toast.showMessage({
|
||||
title: i18n.baseText('aiAssistant.builder.toast.title'),
|
||||
message: i18n.baseText('aiAssistant.builder.toast.description'),
|
||||
type: 'info',
|
||||
});
|
||||
logsStore.toggleOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const runOptions: Parameters<typeof runWorkflow>[0] = {};
|
||||
if (selectedTriggerNode) {
|
||||
runOptions.triggerNode = selectedTriggerNode;
|
||||
}
|
||||
|
||||
await runWorkflow(runOptions);
|
||||
}
|
||||
|
||||
function scrollIntoView() {
|
||||
containerRef.value?.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
|
||||
onMounted(scrollIntoView);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopExecutionWatcher();
|
||||
});
|
||||
|
||||
watch(workflowIssues, async () => {
|
||||
await nextTick();
|
||||
scrollIntoView();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="containerRef"
|
||||
:class="$style.container"
|
||||
role="region"
|
||||
aria-label="Workflow execution panel"
|
||||
>
|
||||
<!-- Validation Issues Section -->
|
||||
<template v-if="hasValidationIssues">
|
||||
<p :class="$style.description">
|
||||
{{ i18n.baseText('aiAssistant.builder.executeMessage.description') }}
|
||||
</p>
|
||||
<TransitionGroup
|
||||
name="fade"
|
||||
tag="ul"
|
||||
:class="$style.issuesList"
|
||||
role="list"
|
||||
aria-label="Workflow validation issues"
|
||||
>
|
||||
<NodeIssueItem
|
||||
v-for="issue in workflowIssues"
|
||||
:key="`${formatIssueMessage(issue.value)}_${issue.node}`"
|
||||
:issue="issue"
|
||||
:get-node-type="getNodeTypeByName"
|
||||
:format-issue-message="formatIssueMessage"
|
||||
/>
|
||||
</TransitionGroup>
|
||||
</template>
|
||||
|
||||
<!-- No Issues Section -->
|
||||
<template v-else>
|
||||
<p :class="$style.noIssuesMessage">
|
||||
{{ i18n.baseText('aiAssistant.builder.executeMessage.noIssues') }}
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<!-- Execution Button -->
|
||||
<N8nTooltip :disabled="!hasValidationIssues" :content="executeButtonTooltip" placement="left">
|
||||
<CanvasRunWorkflowButton
|
||||
:class="$style.runButton"
|
||||
:disabled="hasValidationIssues"
|
||||
:waiting-for-webhook="isExecutionWaitingForWebhook"
|
||||
:hide-tooltip="true"
|
||||
:label="i18n.baseText('aiAssistant.builder.executeMessage.execute')"
|
||||
:executing="isWorkflowRunning"
|
||||
:include-chat-trigger="true"
|
||||
size="medium"
|
||||
:trigger-nodes="availableTriggerNodes"
|
||||
:get-node-type="nodeTypesStore.getNodeType"
|
||||
:selected-trigger-node-name="workflowsStore.selectedTriggerNodeName"
|
||||
@execute="onExecute"
|
||||
@select-trigger-node="workflowsStore.setSelectedTriggerNodeName"
|
||||
/>
|
||||
</N8nTooltip>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* Fade transition animations for issue list */
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.5s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss" module>
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: var(--spacing-xs);
|
||||
gap: var(--spacing-xs);
|
||||
background-color: var(--color-background-xlight);
|
||||
border: var(--border-base);
|
||||
border-radius: var(--border-radius-large);
|
||||
line-height: var(--font-line-height-loose);
|
||||
position: relative;
|
||||
font-size: var(--font-size-2xs);
|
||||
}
|
||||
|
||||
.description {
|
||||
margin: 0;
|
||||
color: var(--color-text-dark);
|
||||
line-height: var(--font-line-height-regular);
|
||||
}
|
||||
|
||||
.noIssuesMessage {
|
||||
margin: 0;
|
||||
color: var(--color-text-dark);
|
||||
}
|
||||
|
||||
.issuesList {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.runButton {
|
||||
align-self: stretch;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { fireEvent } from '@testing-library/vue';
|
||||
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import { mockedStore } from '@/__tests__/utils';
|
||||
import NodeIssueItem from './NodeIssueItem.vue';
|
||||
import type { INodeTypeDescription } from 'n8n-workflow';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { setActivePinia } from 'pinia';
|
||||
import { useNDVStore } from '@/stores/ndv.store';
|
||||
|
||||
vi.mock('@/components/NodeIcon.vue', () => ({
|
||||
default: {
|
||||
template: '<span />',
|
||||
},
|
||||
}));
|
||||
|
||||
const renderComponent = createComponentRenderer(NodeIssueItem);
|
||||
|
||||
describe('NodeIssueItem', () => {
|
||||
let ndvStore: ReturnType<typeof mockedStore<typeof useNDVStore>>;
|
||||
let pinia: ReturnType<typeof createTestingPinia>;
|
||||
|
||||
beforeEach(() => {
|
||||
pinia = createTestingPinia({ stubActions: false });
|
||||
setActivePinia(pinia);
|
||||
ndvStore = mockedStore(useNDVStore);
|
||||
ndvStore.setActiveNodeName = vi.fn();
|
||||
});
|
||||
|
||||
it('renders issue information using provided formatter', () => {
|
||||
const { getByText } = renderComponent({
|
||||
pinia,
|
||||
props: {
|
||||
issue: { node: 'Linear', type: 'parameters', value: 'Missing API key' },
|
||||
getNodeType: vi.fn(),
|
||||
formatIssueMessage: (value: string | string[]) =>
|
||||
Array.isArray(value) ? value.join(', ') : value,
|
||||
},
|
||||
});
|
||||
|
||||
expect(getByText('Linear:')).toBeInTheDocument();
|
||||
expect(getByText('Missing API key')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens NDV on edit button click', async () => {
|
||||
const nodeType = {
|
||||
name: 'n8n-nodes-base.linear',
|
||||
displayName: 'Linear',
|
||||
} as INodeTypeDescription;
|
||||
const { getByLabelText } = renderComponent({
|
||||
pinia,
|
||||
props: {
|
||||
issue: { node: 'Linear', type: 'parameters', value: 'Missing API key' },
|
||||
getNodeType: vi.fn(() => nodeType),
|
||||
formatIssueMessage: (value: string) => value,
|
||||
},
|
||||
});
|
||||
|
||||
await fireEvent.click(getByLabelText('Edit Linear node'));
|
||||
expect(ndvStore.setActiveNodeName).toHaveBeenCalledWith('Linear', 'other');
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
<script setup lang="ts">
|
||||
import type { INodeTypeDescription } from 'n8n-workflow';
|
||||
// Types for node issues
|
||||
interface WorkflowNodeIssue {
|
||||
node: string;
|
||||
type: string;
|
||||
value: string | string[];
|
||||
}
|
||||
import { useNDVStore } from '@/stores/ndv.store';
|
||||
|
||||
interface Props {
|
||||
/** The node issue to display */
|
||||
issue: WorkflowNodeIssue;
|
||||
/** Function to get node type information */
|
||||
getNodeType: (nodeName: string) => INodeTypeDescription | null;
|
||||
/** Function to format issue messages */
|
||||
formatIssueMessage: (value: WorkflowNodeIssue['value']) => string;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const ndvStore = useNDVStore();
|
||||
|
||||
function handleEditClick() {
|
||||
ndvStore.setActiveNodeName(props.issue.node, 'other');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<li
|
||||
:class="$style.nodeIssue"
|
||||
role="listitem"
|
||||
:aria-label="`Edit ${issue.node} node`"
|
||||
@click="handleEditClick"
|
||||
>
|
||||
<!-- Node icon with tooltip -->
|
||||
<NodeIcon
|
||||
:node-type="getNodeType(issue.node)"
|
||||
:size="14"
|
||||
:shrink="false"
|
||||
:show-tooltip="true"
|
||||
tooltip-position="left"
|
||||
:class="$style.nodeIcon"
|
||||
:aria-label="`${issue.node} node`"
|
||||
/>
|
||||
|
||||
<!-- Issue message -->
|
||||
<div :class="$style.issueMessage" :aria-label="`Issue: ${formatIssueMessage(issue.value)}`">
|
||||
<span :class="$style.nodeName">{{ issue.node }}:</span>
|
||||
{{ formatIssueMessage(issue.value) }}
|
||||
</div>
|
||||
|
||||
<!-- Edit button -->
|
||||
<N8nIcon size="large" icon="pencil" />
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.nodeIssue {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: var(--spacing-3xs) 0;
|
||||
border-bottom: 1px solid var(--color-foreground-light);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
&:first-child {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.nodeIcon {
|
||||
margin-right: var(--spacing-2xs);
|
||||
margin-top: var(--spacing-4xs);
|
||||
flex-shrink: 0;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.nodeName {
|
||||
font-weight: var(--font-weight-bold);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.issueMessage {
|
||||
flex: 1;
|
||||
padding-right: var(--spacing-xs);
|
||||
line-height: var(--font-line-height-regular);
|
||||
}
|
||||
|
||||
.editButton {
|
||||
--button-border-color: transparent;
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
align-self: center;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -22,17 +22,23 @@ const props = defineProps<{
|
|||
waitingForWebhook?: boolean;
|
||||
executing?: boolean;
|
||||
disabled?: boolean;
|
||||
hideTooltip?: boolean;
|
||||
label?: string;
|
||||
size?: 'small' | 'medium' | 'large';
|
||||
includeChatTrigger?: boolean;
|
||||
getNodeType: (type: string, typeVersion: number) => INodeTypeDescription | null;
|
||||
}>();
|
||||
|
||||
const i18n = useI18n();
|
||||
|
||||
const selectableTriggerNodes = computed(() =>
|
||||
props.triggerNodes.filter((node) => !node.disabled && !isChatNode(node)),
|
||||
props.triggerNodes.filter(
|
||||
(node) => !node.disabled && (props.includeChatTrigger ? true : !isChatNode(node)),
|
||||
),
|
||||
);
|
||||
const label = computed(() => {
|
||||
if (!props.executing) {
|
||||
return i18n.baseText('nodeView.runButtonText.executeWorkflow');
|
||||
return props.label ?? i18n.baseText('nodeView.runButtonText.executeWorkflow');
|
||||
}
|
||||
|
||||
if (props.waitingForWebhook) {
|
||||
|
|
@ -43,7 +49,7 @@ const label = computed(() => {
|
|||
});
|
||||
const actions = computed(() =>
|
||||
props.triggerNodes
|
||||
.filter((node) => !isChatNode(node))
|
||||
.filter((node) => (props.includeChatTrigger ? true : !isChatNode(node)))
|
||||
.toSorted((a, b) => {
|
||||
const [aX, aY] = a.position;
|
||||
const [bX, bY] = b.position;
|
||||
|
|
@ -77,13 +83,13 @@ function getNodeTypeByName(name: string): INodeTypeDescription | null {
|
|||
<KeyboardShortcutTooltip
|
||||
:label="label"
|
||||
:shortcut="{ metaKey: true, keys: ['↵'] }"
|
||||
:disabled="executing"
|
||||
:disabled="executing || hideTooltip"
|
||||
>
|
||||
<N8nButton
|
||||
:class="$style.button"
|
||||
:loading="executing"
|
||||
:disabled="disabled"
|
||||
size="large"
|
||||
:size="size ?? 'large'"
|
||||
icon="flask-conical"
|
||||
type="primary"
|
||||
data-test-id="execute-workflow-button"
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@
|
|||
/* eslint-disable @typescript-eslint/no-unsafe-call */
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { setActivePinia, createPinia } from 'pinia';
|
||||
import { setActivePinia } from 'pinia';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { ENABLED_VIEWS, useBuilderStore } from '@/stores/builder.store';
|
||||
import { usePostHog } from './posthog.store';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
|
|
@ -20,12 +21,20 @@ import * as telemetryModule from '@/composables/useTelemetry';
|
|||
import type { Telemetry } from '@/plugins/telemetry';
|
||||
import type { ChatUI } from '@n8n/design-system/types/assistant';
|
||||
import { DEFAULT_CHAT_WIDTH, MAX_CHAT_WIDTH, MIN_CHAT_WIDTH } from './assistant.store';
|
||||
import type { INodeTypeDescription } from 'n8n-workflow';
|
||||
import { mockedStore } from '@/__tests__/utils';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||
import { useCredentialsStore } from '@/stores/credentials.store';
|
||||
|
||||
// Mock useI18n to return the keys instead of translations
|
||||
vi.mock('@n8n/i18n', () => ({
|
||||
useI18n: () => ({
|
||||
baseText: (key: string) => key,
|
||||
}),
|
||||
i18n: {
|
||||
baseText: (key: string) => key,
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock useToast
|
||||
|
|
@ -35,31 +44,16 @@ vi.mock('@/composables/useToast', () => ({
|
|||
}),
|
||||
}));
|
||||
|
||||
// Mock the workflows store
|
||||
const mockSetWorkflowName = vi.fn();
|
||||
const mockRemoveAllConnections = vi.fn();
|
||||
const mockRemoveAllNodes = vi.fn();
|
||||
const mockWorkflow = {
|
||||
name: DEFAULT_NEW_WORKFLOW_NAME,
|
||||
nodes: [],
|
||||
connections: {},
|
||||
};
|
||||
|
||||
vi.mock('./workflows.store', () => ({
|
||||
useWorkflowsStore: vi.fn(() => ({
|
||||
workflow: mockWorkflow,
|
||||
workflowId: 'test-workflow-id',
|
||||
allNodes: [],
|
||||
nodesByName: {},
|
||||
workflowExecutionData: null,
|
||||
setWorkflowName: mockSetWorkflowName,
|
||||
removeAllConnections: mockRemoveAllConnections,
|
||||
removeAllNodes: mockRemoveAllNodes,
|
||||
})),
|
||||
}));
|
||||
|
||||
let settingsStore: ReturnType<typeof useSettingsStore>;
|
||||
let posthogStore: ReturnType<typeof usePostHog>;
|
||||
let workflowsStore: ReturnType<typeof mockedStore<typeof useWorkflowsStore>>;
|
||||
let nodeTypesStore: ReturnType<typeof mockedStore<typeof useNodeTypesStore>>;
|
||||
let credentialsStore: ReturnType<typeof mockedStore<typeof useCredentialsStore>>;
|
||||
let pinia: ReturnType<typeof createTestingPinia>;
|
||||
|
||||
let setWorkflowNameSpy: ReturnType<typeof vi.fn>;
|
||||
let getNodeTypeSpy: ReturnType<typeof vi.fn>;
|
||||
let getCredentialsByTypeSpy: ReturnType<typeof vi.fn>;
|
||||
|
||||
const apiSpy = vi.spyOn(chatAPI, 'chatWithBuilder');
|
||||
|
||||
|
|
@ -96,7 +90,8 @@ vi.mock('vue-router', () => ({
|
|||
describe('AI Builder store', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
setActivePinia(createPinia());
|
||||
pinia = createTestingPinia({ stubActions: false });
|
||||
setActivePinia(pinia);
|
||||
settingsStore = useSettingsStore();
|
||||
settingsStore.setSettings(
|
||||
merge({}, defaultSettings, {
|
||||
|
|
@ -110,14 +105,33 @@ describe('AI Builder store', () => {
|
|||
posthogStore = usePostHog();
|
||||
posthogStore.init();
|
||||
track.mockReset();
|
||||
// Reset workflow store mocks
|
||||
mockSetWorkflowName.mockReset();
|
||||
mockRemoveAllConnections.mockReset();
|
||||
mockRemoveAllNodes.mockReset();
|
||||
// Reset workflow to default state
|
||||
mockWorkflow.name = DEFAULT_NEW_WORKFLOW_NAME;
|
||||
mockWorkflow.nodes = [];
|
||||
mockWorkflow.connections = {};
|
||||
|
||||
workflowsStore = mockedStore(useWorkflowsStore);
|
||||
nodeTypesStore = mockedStore(useNodeTypesStore);
|
||||
credentialsStore = mockedStore(useCredentialsStore);
|
||||
|
||||
workflowsStore.workflowId = 'test-workflow-id';
|
||||
workflowsStore.workflow.name = DEFAULT_NEW_WORKFLOW_NAME;
|
||||
workflowsStore.workflow.nodes = [];
|
||||
workflowsStore.workflow.connections = {};
|
||||
workflowsStore.allNodes = [];
|
||||
workflowsStore.nodesByName = {};
|
||||
workflowsStore.workflowExecutionData = null;
|
||||
|
||||
setWorkflowNameSpy = workflowsStore.setWorkflowName.mockImplementation(({ newName }) => {
|
||||
workflowsStore.workflow.name = newName;
|
||||
});
|
||||
|
||||
getNodeTypeSpy = vi.fn();
|
||||
vi.spyOn(nodeTypesStore, 'getNodeType', 'get').mockReturnValue(getNodeTypeSpy);
|
||||
|
||||
getCredentialsByTypeSpy = vi.fn().mockReturnValue([]);
|
||||
vi.spyOn(credentialsStore, 'getCredentialsByType', 'get').mockReturnValue(
|
||||
getCredentialsByTypeSpy,
|
||||
);
|
||||
vi.spyOn(credentialsStore, 'getCredentialTypeByName', 'get').mockReturnValue(
|
||||
vi.fn().mockReturnValue(undefined),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -855,7 +869,7 @@ describe('AI Builder store', () => {
|
|||
builderStore.initialGeneration = true;
|
||||
|
||||
// Ensure workflow has default name
|
||||
mockWorkflow.name = DEFAULT_NEW_WORKFLOW_NAME;
|
||||
workflowsStore.workflow.name = DEFAULT_NEW_WORKFLOW_NAME;
|
||||
|
||||
// Create workflow JSON with a generated name
|
||||
const workflowJson = JSON.stringify({
|
||||
|
|
@ -879,7 +893,7 @@ describe('AI Builder store', () => {
|
|||
expect(result.success).toBe(true);
|
||||
|
||||
// Verify setWorkflowName was called with the generated name
|
||||
expect(mockSetWorkflowName).toHaveBeenCalledWith({
|
||||
expect(setWorkflowNameSpy).toHaveBeenCalledWith({
|
||||
newName: 'Generated Workflow Name for Email Processing',
|
||||
setStateDirty: false,
|
||||
});
|
||||
|
|
@ -892,7 +906,7 @@ describe('AI Builder store', () => {
|
|||
builderStore.initialGeneration = true;
|
||||
|
||||
// Set a custom workflow name (not the default)
|
||||
mockWorkflow.name = 'My Custom Workflow';
|
||||
workflowsStore.workflow.name = 'My Custom Workflow';
|
||||
|
||||
// Create workflow JSON with a generated name
|
||||
const workflowJson = JSON.stringify({
|
||||
|
|
@ -916,7 +930,7 @@ describe('AI Builder store', () => {
|
|||
expect(result.success).toBe(true);
|
||||
|
||||
// Verify setWorkflowName was NOT called
|
||||
expect(mockSetWorkflowName).not.toHaveBeenCalled();
|
||||
expect(setWorkflowNameSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should NOT apply generated workflow name when not initial generation', () => {
|
||||
|
|
@ -926,7 +940,7 @@ describe('AI Builder store', () => {
|
|||
builderStore.initialGeneration = false;
|
||||
|
||||
// Ensure workflow has default name
|
||||
mockWorkflow.name = DEFAULT_NEW_WORKFLOW_NAME;
|
||||
workflowsStore.workflow.name = DEFAULT_NEW_WORKFLOW_NAME;
|
||||
|
||||
// Create workflow JSON with a generated name
|
||||
const workflowJson = JSON.stringify({
|
||||
|
|
@ -950,7 +964,7 @@ describe('AI Builder store', () => {
|
|||
expect(result.success).toBe(true);
|
||||
|
||||
// Verify setWorkflowName was NOT called
|
||||
expect(mockSetWorkflowName).not.toHaveBeenCalled();
|
||||
expect(setWorkflowNameSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle workflow updates without name property', () => {
|
||||
|
|
@ -960,7 +974,7 @@ describe('AI Builder store', () => {
|
|||
builderStore.initialGeneration = true;
|
||||
|
||||
// Ensure workflow has default name
|
||||
mockWorkflow.name = DEFAULT_NEW_WORKFLOW_NAME;
|
||||
workflowsStore.workflow.name = DEFAULT_NEW_WORKFLOW_NAME;
|
||||
|
||||
// Create workflow JSON without a name property
|
||||
const workflowJson = JSON.stringify({
|
||||
|
|
@ -983,7 +997,7 @@ describe('AI Builder store', () => {
|
|||
expect(result.success).toBe(true);
|
||||
|
||||
// Verify setWorkflowName was NOT called
|
||||
expect(mockSetWorkflowName).not.toHaveBeenCalled();
|
||||
expect(setWorkflowNameSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle workflow names that start with but are not exactly the default name', () => {
|
||||
|
|
@ -993,7 +1007,7 @@ describe('AI Builder store', () => {
|
|||
builderStore.initialGeneration = true;
|
||||
|
||||
// Set workflow name that starts with default but has more text
|
||||
mockWorkflow.name = `${DEFAULT_NEW_WORKFLOW_NAME} - Copy`;
|
||||
workflowsStore.workflow.name = `${DEFAULT_NEW_WORKFLOW_NAME} - Copy`;
|
||||
|
||||
// Create workflow JSON with a generated name
|
||||
const workflowJson = JSON.stringify({
|
||||
|
|
@ -1017,7 +1031,7 @@ describe('AI Builder store', () => {
|
|||
expect(result.success).toBe(true);
|
||||
|
||||
// Verify setWorkflowName WAS called because the name starts with default
|
||||
expect(mockSetWorkflowName).toHaveBeenCalledWith({
|
||||
expect(setWorkflowNameSpy).toHaveBeenCalledWith({
|
||||
newName: 'Generated Workflow Name for Email Processing',
|
||||
setStateDirty: false,
|
||||
});
|
||||
|
|
@ -1047,7 +1061,7 @@ describe('AI Builder store', () => {
|
|||
builderStore.initialGeneration = true;
|
||||
|
||||
// Ensure workflow has default name
|
||||
mockWorkflow.name = DEFAULT_NEW_WORKFLOW_NAME;
|
||||
workflowsStore.workflow.name = DEFAULT_NEW_WORKFLOW_NAME;
|
||||
|
||||
// First update with name
|
||||
const workflowJson1 = JSON.stringify({
|
||||
|
|
@ -1057,7 +1071,7 @@ describe('AI Builder store', () => {
|
|||
});
|
||||
|
||||
builderStore.applyWorkflowUpdate(workflowJson1);
|
||||
expect(mockSetWorkflowName).toHaveBeenCalledTimes(1);
|
||||
expect(setWorkflowNameSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
// The flag should still be true for subsequent updates in the same generation
|
||||
expect(builderStore.initialGeneration).toBe(true);
|
||||
|
|
@ -1079,7 +1093,106 @@ describe('AI Builder store', () => {
|
|||
builderStore.applyWorkflowUpdate(workflowJson2);
|
||||
|
||||
// Should not call setWorkflowName again
|
||||
expect(mockSetWorkflowName).toHaveBeenCalledTimes(1);
|
||||
expect(setWorkflowNameSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
describe('applyWorkflowUpdate credential defaults', () => {
|
||||
const createTestNodeType = (): INodeTypeDescription => ({
|
||||
displayName: 'Test Node',
|
||||
name: 'n8n-nodes-base.test',
|
||||
description: 'Test node',
|
||||
group: ['trigger'],
|
||||
version: 1,
|
||||
defaults: { name: 'Test Node' },
|
||||
inputs: ['main'],
|
||||
outputs: ['main'],
|
||||
properties: [
|
||||
{
|
||||
displayName: 'Authentication',
|
||||
name: 'authentication',
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
name: 'API Key',
|
||||
value: 'apiKey',
|
||||
},
|
||||
],
|
||||
default: 'apiKey',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
credentials: [
|
||||
{
|
||||
name: 'testApi',
|
||||
required: true,
|
||||
displayOptions: {
|
||||
show: {
|
||||
authentication: ['apiKey'],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
it('assigns default credentials when available', () => {
|
||||
const builderStore = useBuilderStore();
|
||||
getNodeTypeSpy.mockReturnValue(createTestNodeType());
|
||||
getCredentialsByTypeSpy.mockReturnValue([
|
||||
{ id: 'cred-id', name: 'API Credential', type: 'testApi' },
|
||||
]);
|
||||
|
||||
const workflowJson = JSON.stringify({
|
||||
nodes: [
|
||||
{
|
||||
id: 'node1',
|
||||
name: 'HTTP Request',
|
||||
type: 'n8n-nodes-base.test',
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
},
|
||||
],
|
||||
connections: {},
|
||||
});
|
||||
|
||||
const result = builderStore.applyWorkflowUpdate(workflowJson);
|
||||
expect(result.success).toBe(true);
|
||||
const [node] = result.workflowData?.nodes ?? [];
|
||||
expect(node.credentials).toEqual({
|
||||
testApi: { id: 'cred-id', name: 'API Credential' },
|
||||
});
|
||||
expect(node.parameters.authentication).toBe('apiKey');
|
||||
});
|
||||
|
||||
it('keeps existing credentials untouched', () => {
|
||||
const builderStore = useBuilderStore();
|
||||
getNodeTypeSpy.mockReturnValue(createTestNodeType());
|
||||
getCredentialsByTypeSpy.mockReturnValue([
|
||||
{ id: 'cred-id', name: 'API Credential', type: 'testApi' },
|
||||
]);
|
||||
|
||||
const workflowJson = JSON.stringify({
|
||||
nodes: [
|
||||
{
|
||||
id: 'node1',
|
||||
name: 'HTTP Request',
|
||||
type: 'n8n-nodes-base.test',
|
||||
position: [0, 0],
|
||||
parameters: { authentication: 'apiKey' },
|
||||
credentials: {
|
||||
testApi: { id: 'existing', name: 'Existing Credential' },
|
||||
},
|
||||
},
|
||||
],
|
||||
connections: {},
|
||||
});
|
||||
|
||||
const result = builderStore.applyWorkflowUpdate(workflowJson);
|
||||
expect(result.success).toBe(true);
|
||||
const [node] = result.workflowData?.nodes ?? [];
|
||||
expect(node.credentials).toEqual({
|
||||
testApi: { id: 'existing', name: 'Existing Credential' },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -28,6 +28,9 @@ import type { WorkflowDataUpdate } from '@n8n/rest-api-client/api/workflows';
|
|||
import pick from 'lodash/pick';
|
||||
import { jsonParse } from 'n8n-workflow';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
import { useNodeTypesStore } from './nodeTypes.store';
|
||||
import { useCredentialsStore } from './credentials.store';
|
||||
import { getAuthTypeForNodeCredential, getMainAuthField } from '@/utils/nodeTypesUtils';
|
||||
|
||||
const INFINITE_CREDITS = -1;
|
||||
export const ENABLED_VIEWS = [...EDITABLE_CANVAS_VIEWS];
|
||||
|
|
@ -49,6 +52,9 @@ export const useBuilderStore = defineStore(STORES.BUILDER, () => {
|
|||
const rootStore = useRootStore();
|
||||
const workflowsStore = useWorkflowsStore();
|
||||
const uiStore = useUIStore();
|
||||
const credentialsStore = useCredentialsStore();
|
||||
const nodeTypesStore = useNodeTypesStore();
|
||||
|
||||
const route = useRoute();
|
||||
const locale = useI18n();
|
||||
const telemetry = useTelemetry();
|
||||
|
|
@ -274,12 +280,24 @@ export const useBuilderStore = defineStore(STORES.BUILDER, () => {
|
|||
source?: 'chat' | 'canvas';
|
||||
quickReplyType?: string;
|
||||
initialGeneration?: boolean;
|
||||
type?: 'message' | 'execution';
|
||||
errorMessage?: string;
|
||||
errorNodeType?: string;
|
||||
executionStatus?: string;
|
||||
}) {
|
||||
if (streaming.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { text, source = 'chat', quickReplyType } = options;
|
||||
const {
|
||||
text,
|
||||
source = 'chat',
|
||||
quickReplyType,
|
||||
errorMessage,
|
||||
type = 'message',
|
||||
errorNodeType,
|
||||
executionStatus,
|
||||
} = options;
|
||||
|
||||
// Set initial generation flag if provided
|
||||
if (options.initialGeneration !== undefined) {
|
||||
|
|
@ -288,13 +306,24 @@ export const useBuilderStore = defineStore(STORES.BUILDER, () => {
|
|||
const messageId = generateMessageId();
|
||||
|
||||
const currentWorkflowJson = getWorkflowSnapshot();
|
||||
telemetry.track('User submitted builder message', {
|
||||
const trackingPayload: Record<string, string> = {
|
||||
source,
|
||||
message: text,
|
||||
session_id: trackingSessionId.value,
|
||||
start_workflow_json: currentWorkflowJson,
|
||||
workflow_id: workflowsStore.workflowId,
|
||||
});
|
||||
type,
|
||||
};
|
||||
|
||||
if (type === 'execution') {
|
||||
trackingPayload.execution_status = executionStatus ?? '';
|
||||
if (executionStatus === 'error') {
|
||||
trackingPayload.error_message = errorMessage ?? '';
|
||||
trackingPayload.error_node_type = errorNodeType ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
telemetry.track('User submitted builder message', trackingPayload);
|
||||
|
||||
prepareForStreaming(text, messageId);
|
||||
|
||||
|
|
@ -410,6 +439,48 @@ export const useBuilderStore = defineStore(STORES.BUILDER, () => {
|
|||
};
|
||||
}
|
||||
|
||||
function setDefaultNodesCredentials(workflowData: WorkflowDataUpdate) {
|
||||
// Set default credentials for new nodes if available
|
||||
workflowData.nodes?.forEach((node) => {
|
||||
const hasCredentials = node.credentials && Object.keys(node.credentials).length > 0;
|
||||
if (hasCredentials) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nodeType = nodeTypesStore.getNodeType(node.type);
|
||||
if (!nodeType?.credentials) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to find and set the first available credential
|
||||
for (const credentialConfig of nodeType.credentials) {
|
||||
const credentials = credentialsStore.getCredentialsByType(credentialConfig.name);
|
||||
// No credentials of this type exist, try the next one
|
||||
if (!credentials || credentials.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Found valid credentials - set them and exit the loop
|
||||
const credential = credentials[0];
|
||||
|
||||
node.credentials = {
|
||||
[credential.type]: {
|
||||
id: credential.id,
|
||||
name: credential.name,
|
||||
},
|
||||
};
|
||||
|
||||
const authField = getMainAuthField(nodeType);
|
||||
const authType = getAuthTypeForNodeCredential(nodeType, credentialConfig);
|
||||
if (authField && authType) {
|
||||
node.parameters[authField.name] = authType.value;
|
||||
}
|
||||
|
||||
break; // Exit loop after setting the first valid credential
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function applyWorkflowUpdate(workflowJson: string) {
|
||||
let workflowData: WorkflowDataUpdate;
|
||||
try {
|
||||
|
|
@ -455,6 +526,8 @@ export const useBuilderStore = defineStore(STORES.BUILDER, () => {
|
|||
});
|
||||
}
|
||||
|
||||
setDefaultNodesCredentials(workflowData);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
workflowData,
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import { deepCopy, SEND_AND_WAIT_OPERATION } from 'n8n-workflow';
|
|||
import type {
|
||||
IPinData,
|
||||
IConnection,
|
||||
IConnections,
|
||||
INodeExecutionData,
|
||||
INode,
|
||||
INodeTypeDescription,
|
||||
|
|
@ -150,6 +151,92 @@ describe('useWorkflowsStore', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('workflowValidationIssues', () => {
|
||||
it('collects issues only from connected, enabled nodes', () => {
|
||||
const connections: IConnections = {
|
||||
Start: {
|
||||
main: [
|
||||
[
|
||||
{
|
||||
node: 'Fetch',
|
||||
type: 'main',
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
workflowsStore.workflow.nodes = [
|
||||
{
|
||||
id: 'start',
|
||||
name: 'Start',
|
||||
type: 'n8n-nodes-base.start',
|
||||
typeVersion: 1,
|
||||
parameters: {},
|
||||
position: [0, 0],
|
||||
},
|
||||
{
|
||||
id: 'fetch',
|
||||
name: 'Fetch',
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
typeVersion: 1,
|
||||
parameters: {},
|
||||
issues: {
|
||||
parameters: {
|
||||
url: ['Missing URL', 'Invalid URL.'],
|
||||
},
|
||||
credentials: {
|
||||
httpBasicAuth: ['Credentials not set'],
|
||||
},
|
||||
},
|
||||
position: [300, 0],
|
||||
},
|
||||
{
|
||||
id: 'orphan',
|
||||
name: 'Disconnected',
|
||||
type: 'n8n-nodes-base.set',
|
||||
typeVersion: 1,
|
||||
parameters: {},
|
||||
issues: {
|
||||
parameters: { field: ['Should be ignored'] },
|
||||
},
|
||||
position: [0, 400],
|
||||
},
|
||||
{
|
||||
id: 'disabled',
|
||||
name: 'Disabled Node',
|
||||
type: 'n8n-nodes-base.set',
|
||||
typeVersion: 1,
|
||||
disabled: true,
|
||||
parameters: {},
|
||||
issues: {
|
||||
parameters: { field: ['Disabled issue'] },
|
||||
},
|
||||
position: [0, 600],
|
||||
},
|
||||
];
|
||||
workflowsStore.workflow.connections = connections;
|
||||
|
||||
const issues = workflowsStore.workflowValidationIssues;
|
||||
expect(issues).toEqual([
|
||||
{ node: 'Fetch', type: 'parameters', value: ['Missing URL', 'Invalid URL.'] },
|
||||
{ node: 'Fetch', type: 'credentials', value: ['Credentials not set'] },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatIssueMessage', () => {
|
||||
it('joins array entries and trims trailing period', () => {
|
||||
const message = workflowsStore.formatIssueMessage(['Missing URL', 'Invalid value.']);
|
||||
expect(message).toBe('Missing URL, Invalid value');
|
||||
});
|
||||
|
||||
it('returns string representation for non-array values', () => {
|
||||
expect(workflowsStore.formatIssueMessage('Simple issue.')).toBe('Simple issue.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('allWorkflows', () => {
|
||||
it('should return sorted workflows by name', () => {
|
||||
workflowsStore.setWorkflows([
|
||||
|
|
|
|||
|
|
@ -277,6 +277,66 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
|
|||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* Get detailed validation issues for all connected, enabled nodes
|
||||
*/
|
||||
const workflowValidationIssues = computed(() => {
|
||||
const issues: Array<{
|
||||
node: string;
|
||||
type: string;
|
||||
value: string | string[];
|
||||
}> = [];
|
||||
|
||||
const isStringOrStringArray = (value: unknown): value is string | string[] =>
|
||||
typeof value === 'string' || Array.isArray(value);
|
||||
|
||||
workflow.value.nodes.forEach((node) => {
|
||||
if (!node.issues || node.disabled) return;
|
||||
|
||||
const isConnected =
|
||||
Object.keys(outgoingConnectionsByNodeName(node.name)).length > 0 ||
|
||||
Object.keys(incomingConnectionsByNodeName(node.name)).length > 0;
|
||||
|
||||
if (!isConnected) return;
|
||||
|
||||
Object.entries(node.issues).forEach(([issueType, issueValue]) => {
|
||||
if (!issueValue) return;
|
||||
|
||||
if (typeof issueValue === 'object' && !Array.isArray(issueValue)) {
|
||||
// Handle nested issues (parameters, credentials)
|
||||
Object.entries(issueValue).forEach(([_key, value]) => {
|
||||
if (value) {
|
||||
issues.push({
|
||||
node: node.name,
|
||||
type: issueType,
|
||||
value,
|
||||
});
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Handle direct issues
|
||||
issues.push({
|
||||
node: node.name,
|
||||
type: issueType,
|
||||
value: isStringOrStringArray(issueValue) ? issueValue : String(issueValue),
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return issues;
|
||||
});
|
||||
|
||||
/**
|
||||
* Format issue message for display
|
||||
*/
|
||||
function formatIssueMessage(issue: string | string[]): string {
|
||||
if (Array.isArray(issue)) {
|
||||
return issue.join(', ').replace(/\.$/, '');
|
||||
}
|
||||
return String(issue);
|
||||
}
|
||||
|
||||
const pinnedWorkflowData = computed(() => workflow.value.pinData);
|
||||
|
||||
const executedNode = computed(() => workflowExecutionData.value?.executedNode);
|
||||
|
|
@ -2013,6 +2073,8 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
|
|||
canvasNames,
|
||||
nodesByName,
|
||||
nodesIssuesExist,
|
||||
workflowValidationIssues,
|
||||
formatIssueMessage,
|
||||
pinnedWorkflowData,
|
||||
executedNode,
|
||||
getAllLoadedFinishedExecutions,
|
||||
|
|
|
|||
|
|
@ -1124,22 +1124,28 @@ async function importWorkflowExact({ workflow: workflowData }: { workflow: Workf
|
|||
async function onImportWorkflowDataEvent(data: IDataObject) {
|
||||
const workflowData = data.data as WorkflowDataUpdate;
|
||||
const trackEvents = typeof data.trackEvents === 'boolean' ? data.trackEvents : undefined;
|
||||
|
||||
await importWorkflowData(workflowData, 'file', {
|
||||
viewport: viewportBoundaries.value,
|
||||
regenerateIds: data.regenerateIds === true || data.regenerateIds === undefined,
|
||||
trackEvents,
|
||||
});
|
||||
|
||||
await nextTick();
|
||||
fitView();
|
||||
|
||||
selectNodes(workflowData.nodes?.map((node) => node.id) ?? []);
|
||||
if (data.tidyUp) {
|
||||
const nodesIdsToTidyUp = data.nodesIdsToTidyUp as string[];
|
||||
setTimeout(() => {
|
||||
setTimeout(async () => {
|
||||
canvasEventBus.emit('tidyUp', {
|
||||
source: 'import-workflow-data',
|
||||
nodeIdsFilter: nodesIdsToTidyUp,
|
||||
trackEvents,
|
||||
});
|
||||
|
||||
await nextTick();
|
||||
fitView();
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user