feat: AI workflow builder front-end (no-changelog) (#14820)

Co-authored-by: Giulio Andreini <g.andreini@gmail.com>
This commit is contained in:
oleg
2025-04-28 15:38:32 +02:00
committed by GitHub
parent dbffcdc2ff
commit 97055d5714
56 changed files with 3857 additions and 1067 deletions

View File

@@ -33,7 +33,7 @@
"@langchain/openai": "catalog:",
"@n8n/config": "workspace:*",
"@n8n/di": "workspace:*",
"@n8n_io/ai-assistant-sdk": "1.13.0",
"@n8n_io/ai-assistant-sdk": "catalog:",
"n8n-workflow": "workspace:*",
"zod": "catalog:"
},

View File

@@ -12,6 +12,7 @@ import { connectionComposerChain } from './chains/connection-composer';
import { nodesSelectionChain } from './chains/node-selector';
import { nodesComposerChain } from './chains/nodes-composer';
import { plannerChain } from './chains/planner';
import { validatorChain } from './chains/validator';
import { ILicenseService } from './interfaces';
import { anthropicClaude37Sonnet, gpt41mini } from './llm-config';
import type { MessageResponse } from './types';
@@ -58,19 +59,21 @@ export class AiWorkflowBuilderService {
assert(this.client, 'Client not setup');
// @ts-expect-error getProxyHeaders will only be available after `@n8n_io/ai-assistant-sdk` v1.14.0 is released
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
const authHeaders = (await this.client?.getProxyHeaders(user)) as Record<string, string>;
const authHeaders = await this.client.generateApiProxyCredentials(user);
this.llmSimpleTask = gpt41mini({
baseUrl: baseUrl + '/v1/api-proxy/openai',
// When using api-proxy the key will be populated automatically, we just need to pass a placeholder
apiKey: '_',
headers: authHeaders,
apiKey: '-',
headers: {
Authorization: authHeaders.apiKey,
},
});
this.llmComplexTask = anthropicClaude37Sonnet({
baseUrl: baseUrl + '/v1/api-proxy/anthropic',
apiKey: '_',
headers: authHeaders,
apiKey: '-',
headers: {
Authorization: authHeaders.apiKey,
},
});
return;
}
@@ -97,6 +100,7 @@ export class AiWorkflowBuilderService {
private isWorkflowEvent(eventName: string): boolean {
return [
'prompt_validation',
'generated_steps',
'generated_nodes',
'composed_nodes',
@@ -106,6 +110,33 @@ export class AiWorkflowBuilderService {
}
private getAgent() {
const validatorChainNode = async (
state: typeof WorkflowState.State,
config: RunnableConfig,
): Promise<Partial<typeof WorkflowState.State>> => {
assert(this.llmSimpleTask, 'LLM not setup');
const isWorkflowPrompt = await validatorChain(this.llmSimpleTask).invoke(
{
prompt: state.prompt,
},
config,
);
if (!isWorkflowPrompt) {
await dispatchCustomEvent('prompt_validation', {
role: 'assistant',
type: 'prompt-validation',
isWorkflowPrompt,
id: Date.now().toString(),
});
}
return {
isWorkflowPrompt,
};
};
const plannerChainNode = async (
state: typeof WorkflowState.State,
config: RunnableConfig,
@@ -290,7 +321,7 @@ export class AiWorkflowBuilderService {
///////////////////// Workflow Graph Definition /////////////////////
const workflowGraph = new StateGraph(WorkflowState)
// .addNode('supervisor', supervisorChainNode)
.addNode('validator', validatorChainNode)
.addNode('planner', plannerChainNode)
.addNode('node_selector', nodeSelectionChainNode)
.addNode('nodes_composer', nodesComposerChainNode)
@@ -298,8 +329,12 @@ export class AiWorkflowBuilderService {
.addNode('finalize', generateWorkflowJSON);
// Define the graph edges to set the processing order:
// Start with the planner.
workflowGraph.addEdge(START, 'planner');
// Start with the validator
workflowGraph.addEdge(START, 'validator');
// If validated, continue to planner
workflowGraph.addConditionalEdges('validator', (state) => {
return state.isWorkflowPrompt ? 'planner' : END;
});
// Planner node flows into node selector:
workflowGraph.addEdge('planner', 'node_selector');
// Node selector is followed by nodes composer:
@@ -327,6 +362,7 @@ export class AiWorkflowBuilderService {
steps: [],
nodes: [],
workflowJSON: { nodes: [], connections: {} },
isWorkflowPrompt: false,
next: 'PLAN',
};

View File

@@ -10,18 +10,25 @@ export const plannerPrompt = new SystemMessage(
`You are a Workflow Planner for n8n, a platform that helps users automate processes across different services and APIs.
## Your Task
Convert user requests into clear, sequential workflow steps that can be implemented with n8n nodes.
Convert user requests into clear, sequential workflow steps that can be implemented with n8n nodes. ONLY include steps that are explicitly stated or directly implied in the user request.
## Guidelines
1. Analyze the user request to understand their end goal and required process
2. Break down the automation into logical steps based on complexity - simpler workflows need fewer steps, complex ones may need more
3. Focus on actions (fetch data, transform, filter, send notification, etc.)
3. Focus ONLY on actions mentioned directly in the user prompt
4. Create steps that can be mapped to n8n nodes later
5. Order steps sequentially from trigger to final action
6. Be specific about data transformations needed
7. Include error handling steps when appropriate
6. Be specific about data transformations needed ONLY if mentioned in the request
7. NEVER add extra steps like storing data or sending notifications unless explicitly requested
8. Only recommend raw HTTP requests if you think there isn't a suitable n8n node
## CRITICAL REQUIREMENTS
- DO NOT add any steps not directly mentioned or implied in the user request
- DO NOT assume the user wants to store data in a database unless explicitly stated
- DO NOT assume the user wants to send notifications or emails unless explicitly stated
- DO NOT add any "nice to have" steps that aren't clearly part of the user's request
- Keep the workflow EXACTLY focused on what was requested, nothing more
## Output Format
Return ONLY a JSON object with this structure:
\`\`\`json

View File

@@ -0,0 +1,75 @@
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
import type { AIMessageChunk } from '@langchain/core/messages';
import { SystemMessage } from '@langchain/core/messages';
import { ChatPromptTemplate, HumanMessagePromptTemplate } from '@langchain/core/prompts';
import { DynamicStructuredTool } from '@langchain/core/tools';
import { OperationalError } from 'n8n-workflow';
import { z } from 'zod';
const validatorPrompt = new SystemMessage(
`You are a workflow prompt validator for n8n. You need to analyze the user's prompt and determine
if they're actually trying to build a workflow that connects different online services or automates a task.
A workflow prompt should:
- Describe an automation or integration task
- Potentially mention connecting services (like Google Sheets, Slack, etc.)
- Describe a process that could be broken down into steps
- Mention something that could be automated
Examples of VALID workflow prompts:
- "Create a workflow that sends a Slack message when a new row is added to Google Sheets"
- "I want to automatically save Gmail attachments to Dropbox"
- "Build a workflow that posts new Twitter mentions to a Discord channel"
- "When I get a new lead in my CRM, add them to my email marketing list"
Examples of INVALID workflow prompts:
- "What's the weather like today?"
- "Tell me a joke"
- "What is n8n?"
- "Help me fix my computer"
- "What time is it?"
Analyze the prompt and determine if it's a valid workflow prompt. Respond with just true or false.`,
);
const validatorSchema = z.object({
isWorkflowPrompt: z.boolean(),
});
const validatorTool = new DynamicStructuredTool({
name: 'validate_prompt',
description: 'Validate if the user prompt is a workflow prompt',
schema: validatorSchema,
func: async ({ isWorkflowPrompt }) => {
return { isWorkflowPrompt };
},
});
const humanTemplate = `
<user_prompt>
{prompt}
</user_prompt>
`;
const chatPrompt = ChatPromptTemplate.fromMessages([
validatorPrompt,
HumanMessagePromptTemplate.fromTemplate(humanTemplate),
]);
export const validatorChain = (llm: BaseChatModel) => {
if (!llm.bindTools) {
throw new OperationalError("LLM doesn't support binding tools");
}
return chatPrompt
.pipe(
llm.bindTools([validatorTool], {
tool_choice: validatorTool.name,
}),
)
.pipe((x: AIMessageChunk) => {
const toolCall = x.tool_calls?.[0];
return (toolCall?.args as z.infer<typeof validatorTool.schema>).isWorkflowPrompt;
});
};

View File

@@ -88,6 +88,13 @@ export interface WorkflowConnectionsMessage {
read: boolean;
}
export interface PromptValidationMessage {
role: 'assistant';
type: 'prompt-validation';
isWorkflowPrompt: boolean;
id: string;
}
export type MessageResponse =
| ((
| AssistantChatMessage
@@ -99,6 +106,7 @@ export type MessageResponse =
| WorkflowNodeMessage
| WorkflowComposedMessage
| WorkflowConnectionsMessage
| PromptValidationMessage
) & {
quickReplies?: QuickReplyOption[];
})

View File

@@ -17,6 +17,8 @@ export const WorkflowState = Annotation.Root({
workflowJSON: Annotation<SimpleWorkflow>({
reducer: (x, y) => y ?? x ?? { nodes: [], connections: {} },
}),
// Whether the user prompt is a workflow prompt.
isWorkflowPrompt: Annotation<boolean>({ reducer: (x, y) => y ?? x ?? false }),
// The next phase to be executed in the workflow graph.
next: Annotation<string>({ reducer: (x, y) => y ?? x ?? END, default: () => END }),
});

View File

@@ -103,7 +103,7 @@
"@n8n/task-runner": "workspace:*",
"@n8n/ai-workflow-builder": "workspace:*",
"@n8n/typeorm": "0.3.20-12",
"@n8n_io/ai-assistant-sdk": "1.13.0",
"@n8n_io/ai-assistant-sdk": "catalog:",
"@n8n_io/license-sdk": "2.20.0",
"@oclif/core": "4.0.7",
"@rudderstack/rudder-sdk-node": "2.0.9",

View File

@@ -1 +1,26 @@
@import url('https://fonts.googleapis.com/css?family=Open+Sans&display=swap');
/* Storybook-specific font paths */
@font-face {
font-family: InterVariable;
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url('../src/css/fonts/InterVariable.woff2') format('woff2');
}
@font-face {
font-family: InterVariable;
font-style: italic;
font-weight: 100 900;
font-display: swap;
src: url('../src/css/fonts/InterVariable-Italic.woff2') format('woff2');
}
@font-face {
font-family: CommitMono;
font-style: italic;
font-weight: 100 900;
font-display: swap;
src: url('../src/css/fonts/CommitMonoVariable.woff2') format('woff2');
}
@import '../src/css/_tokens.scss';

View File

@@ -10,7 +10,7 @@ const { t } = useI18n();
const hovering = ref(false);
const props = defineProps<{ unreadCount?: number }>();
const props = defineProps<{ unreadCount?: number; type?: 'assistant' | 'builder' }>();
const emit = defineEmits<{
click: [e: MouseEvent];
@@ -40,7 +40,13 @@ function onMouseLeave() {
<AssistantIcon v-else size="large" :theme="hovering ? 'blank' : 'default'" />
<div v-show="hovering" :class="$style.text">
<div>
<AssistantText :text="t('askAssistantButton.askAssistant')" />
<AssistantText
:text="
type === 'builder'
? t('assistantChat.builder.name')
: t('askAssistantButton.askAssistant')
"
/>
</div>
<div>
<BetaTag />

View File

@@ -25,6 +25,24 @@ const Template: StoryFn = (args, { argTypes }) => ({
methods,
});
const TemplateWithInputPlaceholder: StoryFn = (args, { argTypes }) => ({
setup: () => ({ args }),
props: Object.keys(argTypes),
components: {
AskAssistantChat,
},
template: `
<div style="width:275px; height:500px">
<ask-assistant-chat v-bind="args" >
<template #inputPlaceholder>
<button>Click me</button>
</template>
</ask-assistant-chat>
</div>
`,
methods,
});
export const DefaultPlaceholderChat = Template.bind({});
DefaultPlaceholderChat.args = {
user: {
@@ -33,6 +51,14 @@ DefaultPlaceholderChat.args = {
},
};
export const InputPlaceholderChat = TemplateWithInputPlaceholder.bind({});
DefaultPlaceholderChat.args = {
user: {
firstName: 'Max',
lastName: 'Test',
},
};
export const Chat = Template.bind({});
Chat.args = {
user: {
@@ -78,7 +104,7 @@ Chat.args = {
id: '2',
type: 'block',
role: 'assistant',
title: 'Credential doesnt have correct permissions to send a message',
title: "Credential doesn't have correct permissions to send a message",
content:
'Solution steps:\n1. Lorem ipsum dolor sit amet, consectetur **adipiscing** elit. Proin id nulla placerat, tristique ex at, euismod dui.\n2. Copy this into somewhere\n3. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin id nulla placerat, tristique ex at, euismod dui.\n4. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin id nulla placerat, tristique ex at, euismod dui. \n Testing more code \n - Unordered item 1 \n - Unordered item 2',
read: false,
@@ -117,7 +143,7 @@ JustSummary.args = {
id: '123',
role: 'assistant',
type: 'block',
title: 'Credential doesnt have correct permissions to send a message',
title: "Credential doesn't have correct permissions to send a message",
content:
'Solution steps:\n1. Lorem ipsum dolor sit amet, consectetur **adipiscing** elit. Proin id nulla placerat, tristique ex at, euismod dui.\n2. Copy this into somewhere\n3. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin id nulla placerat, tristique ex at, euismod dui.\n4. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin id nulla placerat, tristique ex at, euismod dui. \n Testing more code \n - Unordered item 1 \n - Unordered item 2',
read: false,
@@ -136,7 +162,7 @@ SummaryTitleStreaming.args = {
id: '123',
role: 'assistant',
type: 'block',
title: 'Credential doesnt have',
title: "Credential doesn't have",
content: '',
read: false,
},
@@ -155,7 +181,7 @@ SummaryContentStreaming.args = {
id: '123',
role: 'assistant',
type: 'block',
title: 'Credential doesnt have correct permissions to send a message',
title: "Credential doesn't have correct permissions to send a message",
content: 'Solution steps:\n1. Lorem ipsum dolor sit amet, consectetur',
read: false,
},
@@ -372,3 +398,94 @@ RichTextMessage.args = {
},
]),
};
export const WorkflowStepsChat = Template.bind({});
WorkflowStepsChat.args = {
user: {
firstName: 'Max',
lastName: 'Test',
},
messages: getMessages([
{
id: '123',
type: 'workflow-step',
role: 'assistant',
steps: [
'Create a new HTTP Trigger node',
'Add a Transform node to process the data',
'Connect to your database using PostgreSQL node',
'Send confirmation email with SendGrid node',
],
read: false,
},
]),
};
export const WorkflowNodesChat = Template.bind({});
WorkflowNodesChat.args = {
user: {
firstName: 'Max',
lastName: 'Test',
},
messages: getMessages([
{
id: '124',
type: 'workflow-node',
role: 'assistant',
nodes: ['HTTP Trigger', 'Transform', 'PostgreSQL', 'SendGrid'],
read: false,
},
]),
};
export const ComposedNodesChat = Template.bind({});
ComposedNodesChat.args = {
user: {
firstName: 'Max',
lastName: 'Test',
},
messages: getMessages([
{
id: '125',
type: 'workflow-composed',
role: 'assistant',
nodes: [
{
name: 'HTTP Trigger',
type: 'n8n-nodes-base.httpTrigger',
parameters: {
path: '/webhook',
authentication: 'none',
},
position: [100, 100],
},
{
name: 'Transform',
type: 'n8n-nodes-base.set',
parameters: {
values: { field: 'value' },
},
position: [300, 100],
},
],
read: false,
},
]),
};
export const RateWorkflowMessage = Template.bind({});
RateWorkflowMessage.args = {
user: {
firstName: 'Max',
lastName: 'Test',
},
messages: getMessages([
{
id: '126',
type: 'rate-workflow',
role: 'assistant',
content: 'Is this workflow helpful?',
read: false,
},
]),
};

View File

@@ -1,39 +1,26 @@
<script setup lang="ts">
import Markdown from 'markdown-it';
import markdownLink from 'markdown-it-link-attributes';
import { computed, ref } from 'vue';
import BlockMessage from './messages/BlockMessage.vue';
import CodeDiffMessage from './messages/CodeDiffMessage.vue';
import ErrorMessage from './messages/ErrorMessage.vue';
import EventMessage from './messages/EventMessage.vue';
import TextMessage from './messages/TextMessage.vue';
import ComposedNodesMessage from './messages/workflow/ComposedNodesMessage.vue';
import RateWorkflowMessage from './messages/workflow/RateWorkflowMessage.vue';
import WorkflowGeneratedMessage from './messages/workflow/WorkflowGeneratedMessage.vue';
import WorkflowNodesMessage from './messages/workflow/WorkflowNodesMessage.vue';
import WorkflowStepsMessage from './messages/workflow/WorkflowStepsMessage.vue';
import { useI18n } from '../../composables/useI18n';
import type { ChatUI } from '../../types/assistant';
import AssistantAvatar from '../AskAssistantAvatar/AssistantAvatar.vue';
import AssistantIcon from '../AskAssistantIcon/AssistantIcon.vue';
import AssistantLoadingMessage from '../AskAssistantLoadingMessage/AssistantLoadingMessage.vue';
import AssistantText from '../AskAssistantText/AssistantText.vue';
import BetaTag from '../BetaTag/BetaTag.vue';
import BlinkingCursor from '../BlinkingCursor/BlinkingCursor.vue';
import CodeDiff from '../CodeDiff/CodeDiff.vue';
import InlineAskAssistantButton from '../InlineAskAssistantButton/InlineAskAssistantButton.vue';
const { t } = useI18n();
const md = new Markdown({
breaks: true,
});
md.use(markdownLink, {
attrs: {
target: '_blank',
rel: 'noopener',
},
});
// Wrap tables in div
md.renderer.rules.table_open = function () {
return '<div class="table-wrapper"><table>';
};
md.renderer.rules.table_close = function () {
return '</table></div>';
};
const MAX_CHAT_INPUT_HEIGHT = 100;
interface Props {
@@ -45,6 +32,8 @@ interface Props {
streaming?: boolean;
loadingMessage?: string;
sessionId?: string;
title?: string;
placeholder?: string;
}
const emit = defineEmits<{
@@ -52,11 +41,23 @@ const emit = defineEmits<{
message: [string, string?, boolean?];
codeReplace: [number];
codeUndo: [number];
thumbsUp: [];
thumbsDown: [];
submitFeedback: [string];
}>();
const onClose = () => emit('close');
const props = defineProps<Props>();
const props = withDefaults(defineProps<Props>(), {
title: () => useI18n().t('assistantChat.aiAssistantLabel'),
user: () => ({
firstName: '',
lastName: '',
}),
messages: () => [],
loadingMessage: undefined,
sessionId: undefined,
});
const textInputValue = ref<string>('');
@@ -74,10 +75,6 @@ const showPlaceholder = computed(() => {
return !props.messages?.length && !props.loadingMessage && !props.sessionId;
});
const isClipboardSupported = computed(() => {
return navigator.clipboard?.writeText;
});
function isEndOfSessionEvent(event?: ChatUI.AssistantMessage) {
return event?.type === 'event' && event?.eventName === 'end-session';
}
@@ -95,15 +92,6 @@ function onSendMessage() {
}
}
function renderMarkdown(content: string) {
try {
return md.render(content);
} catch (e) {
console.error(`Error parsing markdown content ${content}`);
return `<p>${t('assistantChat.errorParsingMarkdown')}</p>`;
}
}
function growInput() {
if (!chatInput.value) return;
chatInput.value.style.height = 'auto';
@@ -111,13 +99,16 @@ function growInput() {
chatInput.value.style.height = `${Math.min(scrollHeight, MAX_CHAT_INPUT_HEIGHT)}px`;
}
async function onCopyButtonClick(content: string, e: MouseEvent) {
const button = e.target as HTMLButtonElement;
await navigator.clipboard.writeText(content);
button.innerText = t('assistantChat.copied');
setTimeout(() => {
button.innerText = t('assistantChat.copy');
}, 2000);
function onThumbsUp() {
emit('thumbsUp');
}
function onThumbsDown() {
emit('thumbsDown');
}
function onSubmitFeedback(feedback: string) {
emit('submitFeedback', feedback);
}
</script>
@@ -127,9 +118,10 @@ async function onCopyButtonClick(content: string, e: MouseEvent) {
<div :class="$style.chatTitle">
<div :class="$style.headerText">
<AssistantIcon size="large" />
<AssistantText size="large" :text="t('assistantChat.aiAssistantLabel')" />
<AssistantText size="large" :text="title" />
</div>
<BetaTag />
<slot name="header" />
</div>
<div :class="$style.back" data-test-id="close-chat-button" @click="onClose">
<n8n-icon icon="arrow-right" color="text-base" />
@@ -138,140 +130,91 @@ async function onCopyButtonClick(content: string, e: MouseEvent) {
<div :class="$style.body">
<div v-if="messages?.length || loadingMessage" :class="$style.messages">
<div v-if="messages?.length">
<div
<data
v-for="(message, i) in messages"
:key="i"
:class="$style.message"
:data-test-id="
message.role === 'assistant' ? 'chat-message-assistant' : 'chat-message-user'
"
>
<div
v-if="
!isEndOfSessionEvent(message) && (i === 0 || message.role !== messages[i - 1].role)
"
:class="{ [$style.roleName]: true, [$style.userSection]: i > 0 }"
>
<AssistantAvatar v-if="message.role === 'assistant'" />
<n8n-avatar
v-else
:first-name="user?.firstName"
:last-name="user?.lastName"
size="xsmall"
/>
<span v-if="message.role === 'assistant'">{{
t('assistantChat.aiAssistantName')
}}</span>
<span v-else>{{ t('assistantChat.you') }}</span>
</div>
<div v-if="message.type === 'block'">
<div :class="$style.block">
<div :class="$style.blockTitle">
{{ message.title }}
<BlinkingCursor
v-if="streaming && i === messages?.length - 1 && !message.content"
/>
</div>
<div :class="$style.blockBody">
<span
v-n8n-html="renderMarkdown(message.content)"
:class="$style['rendered-content']"
></span>
<BlinkingCursor
v-if="
streaming && i === messages?.length - 1 && message.title && message.content
"
/>
</div>
</div>
</div>
<div v-else-if="message.type === 'text'" :class="$style.textMessage">
<span
v-if="message.role === 'user'"
v-n8n-html="renderMarkdown(message.content)"
:class="$style['rendered-content']"
></span>
<div
v-else
v-n8n-html="renderMarkdown(message.content)"
:class="[$style.assistantText, $style['rendered-content']]"
></div>
<div
v-if="message?.codeSnippet"
:class="$style['code-snippet']"
data-test-id="assistant-code-snippet"
>
<header v-if="isClipboardSupported">
<n8n-button
type="tertiary"
text="true"
size="mini"
data-test-id="assistant-copy-snippet-button"
@click="onCopyButtonClick(message.codeSnippet, $event)"
>
{{ t('assistantChat.copy') }}
</n8n-button>
</header>
<div
v-n8n-html="renderMarkdown(message.codeSnippet).trim()"
data-test-id="assistant-code-snippet-content"
:class="[$style['snippet-content'], $style['rendered-content']]"
></div>
</div>
<BlinkingCursor
v-if="streaming && i === messages?.length - 1 && message.role === 'assistant'"
/>
</div>
<div
<TextMessage
v-if="message.type === 'text'"
:message="message"
:is-first-of-role="i === 0 || message.role !== messages[i - 1].role"
:user="user"
:streaming="streaming"
:is-last-message="i === messages.length - 1"
/>
<BlockMessage
v-else-if="message.type === 'block'"
:message="message"
:is-first-of-role="i === 0 || message.role !== messages[i - 1].role"
:user="user"
:streaming="streaming"
:is-last-message="i === messages.length - 1"
/>
<ErrorMessage
v-else-if="message.type === 'error'"
:class="$style.error"
data-test-id="chat-message-system"
>
<span>⚠️ {{ message.content }}</span>
<n8n-button
v-if="message.retry"
type="secondary"
size="mini"
:class="$style.retryButton"
data-test-id="error-retry-button"
@click="() => message.retry?.()"
>
{{ t('generic.retry') }}
</n8n-button>
</div>
<div v-else-if="message.type === 'code-diff'">
<CodeDiff
:title="message.description"
:content="message.codeDiff"
:replacing="message.replacing"
:replaced="message.replaced"
:error="message.error"
:streaming="streaming && i === messages?.length - 1"
@replace="() => emit('codeReplace', i)"
@undo="() => emit('codeUndo', i)"
/>
</div>
<div
v-else-if="isEndOfSessionEvent(message)"
:class="$style.endOfSessionText"
data-test-id="chat-message-system"
>
<span>
{{ t('assistantChat.sessionEndMessage.1') }}
</span>
<InlineAskAssistantButton size="small" :static="true" />
<span>
{{ t('assistantChat.sessionEndMessage.2') }}
</span>
</div>
:message="message"
:is-first-of-role="i === 0 || message.role !== messages[i - 1].role"
:user="user"
/>
<EventMessage
v-else-if="message.type === 'event'"
:message="message"
:is-first-of-role="i === 0 || message.role !== messages[i - 1].role"
:user="user"
/>
<CodeDiffMessage
v-else-if="message.type === 'code-diff'"
:message="message"
:is-first-of-role="i === 0 || message.role !== messages[i - 1].role"
:user="user"
:streaming="streaming"
:is-last-message="i === messages.length - 1"
@code-replace="() => emit('codeReplace', i)"
@code-undo="() => emit('codeUndo', i)"
/>
<WorkflowStepsMessage
v-else-if="message.type === 'workflow-step'"
:message="message"
:is-first-of-role="i === 0 || message.role !== messages[i - 1].role"
:user="user"
/>
<WorkflowNodesMessage
v-else-if="message.type === 'workflow-node'"
:message="message"
:is-first-of-role="i === 0 || message.role !== messages[i - 1].role"
:user="user"
/>
<ComposedNodesMessage
v-else-if="message.type === 'workflow-composed'"
:message="message"
:is-first-of-role="i === 0 || message.role !== messages[i - 1].role"
:user="user"
/>
<WorkflowGeneratedMessage
v-else-if="message.type === 'workflow-generated'"
:message="message"
:is-first-of-role="i === 0 || message.role !== messages[i - 1].role"
:user="user"
/>
<RateWorkflowMessage
v-else-if="message.type === 'rate-workflow'"
:message="message"
:is-first-of-role="i === 0 || message.role !== messages[i - 1].role"
:user="user"
@thumbs-up="onThumbsUp"
@thumbs-down="onThumbsDown"
@submit-feedback="onSubmitFeedback"
/>
<div
v-if="
!streaming &&
'quickReplies' in message &&
message.quickReplies?.length &&
i === messages?.length - 1
i === messages.length - 1
"
:class="$style.quickReplies"
>
@@ -289,7 +232,7 @@ async function onCopyButtonClick(content: string, e: MouseEvent) {
</n8n-button>
</div>
</div>
</div>
</data>
</div>
<div
v-if="loadingMessage"
@@ -303,48 +246,59 @@ async function onCopyButtonClick(content: string, e: MouseEvent) {
:class="$style.placeholder"
data-test-id="placeholder-message"
>
<div :class="$style.greeting">Hi {{ user?.firstName }} 👋</div>
<div :class="$style.info">
<p>
{{ t('assistantChat.placeholder.1') }}
</p>
<p>
{{ t('assistantChat.placeholder.2') }}
<InlineAskAssistantButton size="small" :static="true" />
{{ t('assistantChat.placeholder.3') }}
</p>
<p>
{{ t('assistantChat.placeholder.4') }}
</p>
<div v-if="$slots.placeholder" :class="$style.info">
<slot name="placeholder" />
</div>
<template v-else>
<div :class="$style.greeting">Hi {{ user?.firstName }} 👋</div>
<div :class="$style.info">
<p>
{{ t('assistantChat.placeholder.1') }}
</p>
<p>
{{ t('assistantChat.placeholder.2') }}
<InlineAskAssistantButton size="small" :static="true" />
{{ t('assistantChat.placeholder.3') }}
</p>
<p>
{{ t('assistantChat.placeholder.4') }}
</p>
</div>
</template>
</div>
</div>
<div
:class="{ [$style.inputWrapper]: true, [$style.disabledInput]: sessionEnded }"
data-test-id="chat-input-wrapper"
>
<textarea
ref="chatInput"
v-model="textInputValue"
class="ignore-key-press-node-creator ignore-key-press-canvas"
:disabled="sessionEnded"
:placeholder="t('assistantChat.inputPlaceholder')"
rows="1"
wrap="hard"
data-test-id="chat-input"
@keydown.enter.exact.prevent="onSendMessage"
@input.prevent="growInput"
@keydown.stop
/>
<n8n-icon-button
:class="{ [$style.sendButton]: true }"
icon="paper-plane"
type="text"
size="large"
data-test-id="send-message-button"
:disabled="sendDisabled"
@click="onSendMessage"
/>
<div v-if="$slots.inputPlaceholder" :class="$style.inputPlaceholder">
<slot name="inputPlaceholder" />
</div>
<template v-else>
<textarea
ref="chatInput"
v-model="textInputValue"
class="ignore-key-press-node-creator ignore-key-press-canvas"
:class="{ [$style.disabled]: sessionEnded || streaming }"
:disabled="sessionEnded || streaming"
:placeholder="placeholder ?? t('assistantChat.inputPlaceholder')"
rows="1"
wrap="hard"
data-test-id="chat-input"
@keydown.enter.exact.prevent="onSendMessage"
@input.prevent="growInput"
@keydown.stop
/>
<n8n-icon-button
:class="{ [$style.sendButton]: true }"
icon="paper-plane"
type="text"
size="large"
data-test-id="send-message-button"
:disabled="sendDisabled"
@click="onSendMessage"
/>
</template>
</div>
</div>
</template>
@@ -357,11 +311,6 @@ async function onCopyButtonClick(content: string, e: MouseEvent) {
grid-template-rows: auto 1fr auto;
}
p {
line-height: var(--font-line-height-xloose);
margin: var(--spacing-2xs) 0;
}
.header {
height: 65px; // same as header height in editor
padding: 0 var(--spacing-l);
@@ -420,23 +369,6 @@ p {
}
}
.roleName {
display: flex;
align-items: center;
margin-bottom: var(--spacing-3xs);
font-weight: var(--font-weight-bold);
font-size: var(--font-size-2xs);
> * {
margin-right: var(--spacing-3xs);
}
}
.userSection {
margin-top: var(--spacing-l);
}
.chatTitle {
display: flex;
gap: var(--spacing-3xs);
@@ -477,78 +409,6 @@ p {
color: var(--color-text-base);
}
.textMessage {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
font-size: var(--font-size-2xs);
word-break: break-word;
}
code[class^='language-'] {
display: block;
padding: var(--spacing-4xs);
}
.code-snippet {
position: relative;
border: var(--border-base);
background-color: var(--color-foreground-xlight);
border-radius: var(--border-radius-base);
font-family: var(--font-family-monospace);
font-size: var(--font-size-3xs);
max-height: 218px; // 12 lines
overflow: auto;
margin: var(--spacing-4s) 0;
header {
display: flex;
justify-content: flex-end;
padding: var(--spacing-4xs);
border-bottom: var(--border-base);
button:active,
button:focus {
outline: none !important;
}
}
.snippet-content {
padding: var(--spacing-2xs);
}
pre {
white-space-collapse: collapse;
}
code {
background-color: transparent;
font-size: var(--font-size-3xs);
}
}
.block {
font-size: var(--font-size-2xs);
background-color: var(--color-foreground-xlight);
border: var(--border-base);
border-radius: var(--border-radius-base);
word-break: break-word;
li {
margin-left: var(--spacing-xs);
}
}
.blockTitle {
border-bottom: var(--border-base);
padding: var(--spacing-2xs);
font-weight: var(--font-weight-bold);
}
.blockBody {
padding: var(--spacing-xs);
}
.inputWrapper {
display: flex;
background-color: var(--color-foreground-xlight);
@@ -577,91 +437,6 @@ code[class^='language-'] {
}
}
.error {
color: var(--color-danger);
display: flex;
flex-direction: column;
align-items: start;
}
.retryButton {
margin-top: var(--spacing-3xs);
}
.assistantText {
display: inline-flex;
flex-direction: column;
}
.rendered-content {
p {
margin: 0;
margin: var(--spacing-4xs) 0;
}
h1,
h2,
h3 {
font-weight: var(--font-weight-bold);
font-size: var(--font-size-xs);
margin: var(--spacing-xs) 0 var(--spacing-4xs);
}
h4,
h5,
h6 {
font-weight: var(--font-weight-bold);
font-size: var(--font-size-2xs);
}
ul,
ol {
margin: var(--spacing-4xs) 0 var(--spacing-4xs) var(--spacing-l);
ul,
ol {
margin-left: var(--spacing-xs);
margin-top: var(--spacing-4xs);
}
}
:global(.table-wrapper) {
overflow-x: auto;
}
table {
margin: var(--spacing-4xs) 0;
th {
white-space: nowrap;
min-width: 120px;
width: auto;
}
th,
td {
border: var(--border-base);
padding: var(--spacing-4xs);
}
}
}
.endOfSessionText {
margin-top: var(--spacing-l);
padding-top: var(--spacing-3xs);
border-top: var(--border-base);
color: var(--color-text-base);
> button,
> span {
margin-right: var(--spacing-3xs);
}
button {
display: inline-flex;
}
}
.disabledInput {
cursor: not-allowed;
@@ -669,4 +444,13 @@ code[class^='language-'] {
cursor: not-allowed;
}
}
textarea.disabled {
background-color: var(--color-foreground-base);
cursor: not-allowed;
}
.inputPlaceholder {
width: 100%;
}
</style>

View File

@@ -0,0 +1,65 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useI18n } from '../../../composables/useI18n';
import type { ChatUI } from '../../../types/assistant';
import AssistantAvatar from '../../AskAssistantAvatar/AssistantAvatar.vue';
interface Props {
message: ChatUI.AssistantMessage;
isFirstOfRole: boolean;
user?: {
firstName: string;
lastName: string;
};
}
const props = defineProps<Props>();
const { t } = useI18n();
const isUserMessage = computed(() => props.message.role === 'user');
</script>
<template>
<div :class="$style.message">
<div
v-if="isFirstOfRole"
:class="{ [$style.roleName]: true, [$style.userSection]: !isUserMessage }"
>
<template v-if="isUserMessage">
<n8n-avatar :first-name="user?.firstName" :last-name="user?.lastName" size="xsmall" />
<span>{{ t('assistantChat.you') }}</span>
</template>
<template v-else>
<AssistantAvatar />
<span>{{ t('assistantChat.aiAssistantName') }}</span>
</template>
</div>
<slot></slot>
</div>
</template>
<style lang="scss" module>
.message {
margin-bottom: var(--spacing-xs);
font-size: var(--font-size-2xs);
line-height: var(--font-line-height-xloose);
}
.roleName {
display: flex;
align-items: center;
margin-bottom: var(--spacing-3xs);
height: var(--spacing-xl);
font-weight: var(--font-weight-bold);
font-size: var(--font-size-2xs);
> * {
margin-right: var(--spacing-3xs);
}
}
.userSection {
margin-top: var(--spacing-m);
}
</style>

View File

@@ -0,0 +1,115 @@
<script setup lang="ts">
import BaseMessage from './BaseMessage.vue';
import { useMarkdown } from './useMarkdown';
import type { ChatUI } from '../../../types/assistant';
import BlinkingCursor from '../../BlinkingCursor/BlinkingCursor.vue';
interface Props {
message: ChatUI.SummaryBlock & { id: string; read: boolean; quickReplies?: ChatUI.QuickReply[] };
isFirstOfRole: boolean;
user?: {
firstName: string;
lastName: string;
};
streaming?: boolean;
isLastMessage?: boolean;
}
defineProps<Props>();
const { renderMarkdown } = useMarkdown();
</script>
<template>
<BaseMessage :message="message" :is-first-of-role="isFirstOfRole" :user="user">
<div :class="$style.block">
<div :class="$style.blockTitle">
{{ message.title }}
<BlinkingCursor v-if="streaming && isLastMessage && !message.content" />
</div>
<div :class="$style.blockBody">
<span
v-n8n-html="renderMarkdown(message.content)"
:class="$style['rendered-content']"
></span>
<BlinkingCursor v-if="streaming && isLastMessage && message.title && message.content" />
</div>
</div>
</BaseMessage>
</template>
<style lang="scss" module>
.block {
font-size: var(--font-size-2xs);
background-color: var(--color-foreground-xlight);
border: var(--border-base);
border-radius: var(--border-radius-base);
word-break: break-word;
li {
margin-left: var(--spacing-xs);
}
}
.blockTitle {
border-bottom: var(--border-base);
padding: var(--spacing-2xs);
font-weight: var(--font-weight-bold);
}
.blockBody {
padding: var(--spacing-xs);
}
.rendered-content {
p {
margin: 0;
margin: var(--spacing-4xs) 0;
}
h1,
h2,
h3 {
font-weight: var(--font-weight-bold);
font-size: var(--font-size-xs);
margin: var(--spacing-xs) 0 var(--spacing-4xs);
}
h4,
h5,
h6 {
font-weight: var(--font-weight-bold);
font-size: var(--font-size-2xs);
}
ul,
ol {
margin: var(--spacing-4xs) 0 var(--spacing-4xs) var(--spacing-l);
ul,
ol {
margin-left: var(--spacing-xs);
margin-top: var(--spacing-4xs);
}
}
:global(.table-wrapper) {
overflow-x: auto;
}
table {
margin: var(--spacing-4xs) 0;
th {
white-space: nowrap;
min-width: 120px;
width: auto;
}
th,
td {
border: var(--border-base);
padding: var(--spacing-4xs);
}
}
}
</style>

View File

@@ -0,0 +1,50 @@
<script setup lang="ts">
import BaseMessage from './BaseMessage.vue';
import type { ChatUI } from '../../../types/assistant';
import CodeDiff from '../../CodeDiff/CodeDiff.vue';
interface Props {
message: {
role: 'assistant';
type: 'code-diff';
description?: string;
codeDiff?: string;
replacing?: boolean;
replaced?: boolean;
error?: boolean;
suggestionId: string;
id: string;
read: boolean;
quickReplies?: ChatUI.QuickReply[];
};
isFirstOfRole: boolean;
user?: {
firstName: string;
lastName: string;
};
streaming?: boolean;
isLastMessage?: boolean;
}
defineProps<Props>();
const emit = defineEmits<{
codeReplace: [];
codeUndo: [];
}>();
</script>
<template>
<BaseMessage :message="message" :is-first-of-role="isFirstOfRole" :user="user">
<CodeDiff
:title="message.description"
:content="message.codeDiff"
:replacing="message.replacing"
:replaced="message.replaced"
:error="message.error"
:streaming="streaming && isLastMessage"
@replace="emit('codeReplace')"
@undo="emit('codeUndo')"
/>
</BaseMessage>
</template>

View File

@@ -0,0 +1,66 @@
<script setup lang="ts">
import BaseMessage from './BaseMessage.vue';
import { useI18n } from '../../../composables/useI18n';
import type { ChatUI } from '../../../types/assistant';
interface Props {
message: ChatUI.ErrorMessage & { id: string; read: boolean };
isFirstOfRole: boolean;
user?: {
firstName: string;
lastName: string;
};
}
defineProps<Props>();
const { t } = useI18n();
</script>
<template>
<BaseMessage :message="message" :is-first-of-role="isFirstOfRole" :user="user">
<div :class="$style.error" data-test-id="chat-message-system">
<p :class="$style.errorText">
<n8n-icon icon="exclamation-triangle" size="small" :class="$style.errorIcon" />
{{ message.content }}
</p>
<n8n-button
v-if="message.retry"
type="secondary"
size="mini"
:class="$style.retryButton"
data-test-id="error-retry-button"
@click="() => message.retry?.()"
>
{{ t('generic.retry') }}
</n8n-button>
</div>
</BaseMessage>
</template>
<style lang="scss" module>
.error {
display: flex;
align-items: center;
gap: var(--spacing-2xs);
padding: var(--spacing-2xs) var(--spacing-xs);
border: 1px solid var(--color-foreground-base);
border-radius: var(--border-radius-base);
background-color: var(--color-background-xlight);
}
.errorIcon {
margin-right: var(--spacing-5xs);
color: var(--color-danger);
}
.errorText {
color: var(--color-danger);
font-weight: var(--font-weight-regular);
line-height: var(--font-line-height-tight);
word-break: break-word;
}
.retryButton {
margin-top: var(--spacing-3xs);
}
</style>

View File

@@ -0,0 +1,72 @@
<script setup lang="ts">
import BaseMessage from './BaseMessage.vue';
import { useI18n } from '../../../composables/useI18n';
import InlineAskAssistantButton from '../../InlineAskAssistantButton/InlineAskAssistantButton.vue';
type EventName = 'end-session' | 'session-timeout' | 'session-error';
interface Props {
message: {
role: 'assistant';
type: 'event';
eventName: EventName;
id: string;
read: boolean;
};
isFirstOfRole: boolean;
user?: {
firstName: string;
lastName: string;
};
}
defineProps<Props>();
const { t } = useI18n();
const eventMessages: Record<EventName, { part1: string; part2: string }> = {
'end-session': {
part1: 'assistantChat.sessionEndMessage.1',
part2: 'assistantChat.sessionEndMessage.2',
},
'session-timeout': {
part1: 'assistantChat.sessionTimeoutMessage.1',
part2: 'assistantChat.sessionTimeoutMessage.2',
},
'session-error': {
part1: 'assistantChat.sessionErrorMessage.1',
part2: 'assistantChat.sessionErrorMessage.2',
},
} as const;
</script>
<template>
<BaseMessage :message="message" :is-first-of-role="isFirstOfRole" :user="user">
<div :class="$style.eventText" data-test-id="chat-message-system">
<span>
{{ t(eventMessages[message.eventName]?.part1 || 'assistantChat.unknownEvent') }}
</span>
<InlineAskAssistantButton size="small" :static="true" />
<span>
{{ t(eventMessages[message.eventName]?.part2 || '') }}
</span>
</div>
</BaseMessage>
</template>
<style lang="scss" module>
.eventText {
margin-top: var(--spacing-l);
padding-top: var(--spacing-3xs);
border-top: var(--border-base);
color: var(--color-text-base);
> button,
> span {
margin-right: var(--spacing-3xs);
}
button {
display: inline-flex;
}
}
</style>

View File

@@ -0,0 +1,182 @@
<script setup lang="ts">
import { computed } from 'vue';
import BaseMessage from './BaseMessage.vue';
import { useMarkdown } from './useMarkdown';
import { useI18n } from '../../../composables/useI18n';
import type { ChatUI } from '../../../types/assistant';
import BlinkingCursor from '../../BlinkingCursor/BlinkingCursor.vue';
interface Props {
message: ChatUI.TextMessage & { id: string; read: boolean; quickReplies?: ChatUI.QuickReply[] };
isFirstOfRole: boolean;
user?: {
firstName: string;
lastName: string;
};
streaming?: boolean;
isLastMessage?: boolean;
}
defineProps<Props>();
const { renderMarkdown } = useMarkdown();
const { t } = useI18n();
const isClipboardSupported = computed(() => {
return navigator.clipboard?.writeText;
});
async function onCopyButtonClick(content: string, e: MouseEvent) {
const button = e.target as HTMLButtonElement;
await navigator.clipboard.writeText(content);
button.innerText = t('assistantChat.copied');
setTimeout(() => {
button.innerText = t('assistantChat.copy');
}, 2000);
}
</script>
<template>
<BaseMessage :message="message" :is-first-of-role="isFirstOfRole" :user="user">
<div :class="$style.textMessage">
<span
v-if="message.role === 'user'"
v-n8n-html="renderMarkdown(message.content)"
:class="$style['rendered-content']"
></span>
<div
v-else
v-n8n-html="renderMarkdown(message.content)"
:class="[$style.assistantText, $style['rendered-content']]"
></div>
<div
v-if="message?.codeSnippet"
:class="$style['code-snippet']"
data-test-id="assistant-code-snippet"
>
<header v-if="isClipboardSupported">
<n8n-button
type="tertiary"
text="true"
size="mini"
data-test-id="assistant-copy-snippet-button"
@click="onCopyButtonClick(message.codeSnippet, $event)"
>
{{ t('assistantChat.copy') }}
</n8n-button>
</header>
<div
v-n8n-html="renderMarkdown(message.codeSnippet).trim()"
data-test-id="assistant-code-snippet-content"
:class="[$style['snippet-content'], $style['rendered-content']]"
></div>
</div>
<BlinkingCursor v-if="streaming && isLastMessage && message.role === 'assistant'" />
</div>
</BaseMessage>
</template>
<style lang="scss" module>
.textMessage {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
font-size: var(--font-size-2xs);
word-break: break-word;
}
.code-snippet {
position: relative;
border: var(--border-base);
background-color: var(--color-foreground-xlight);
border-radius: var(--border-radius-base);
font-family: var(--font-family-monospace);
font-size: var(--font-size-3xs);
max-height: 218px; // 12 lines
overflow: auto;
margin: var(--spacing-4s) 0;
header {
display: flex;
justify-content: flex-end;
padding: var(--spacing-4xs);
border-bottom: var(--border-base);
button:active,
button:focus {
outline: none !important;
}
}
.snippet-content {
padding: var(--spacing-2xs);
}
pre {
white-space-collapse: collapse;
}
code {
background-color: transparent;
font-size: var(--font-size-3xs);
}
}
.assistantText {
display: inline-flex;
flex-direction: column;
}
.rendered-content {
p {
margin: 0;
margin: var(--spacing-4xs) 0;
}
h1,
h2,
h3 {
font-weight: var(--font-weight-bold);
font-size: var(--font-size-xs);
margin: var(--spacing-xs) 0 var(--spacing-4xs);
}
h4,
h5,
h6 {
font-weight: var(--font-weight-bold);
font-size: var(--font-size-2xs);
}
ul,
ol {
margin: var(--spacing-4xs) 0 var(--spacing-4xs) var(--spacing-l);
ul,
ol {
margin-left: var(--spacing-xs);
margin-top: var(--spacing-4xs);
}
}
:global(.table-wrapper) {
overflow-x: auto;
}
table {
margin: var(--spacing-4xs) 0;
th {
white-space: nowrap;
min-width: 120px;
width: auto;
}
th,
td {
border: var(--border-base);
padding: var(--spacing-4xs);
}
}
}
</style>

View File

@@ -0,0 +1,32 @@
import Markdown from 'markdown-it';
import markdownLink from 'markdown-it-link-attributes';
import { useI18n } from '../../../composables/useI18n';
export function useMarkdown() {
const { t } = useI18n();
const md = new Markdown({
breaks: true,
});
md.use(markdownLink, {
attrs: {
target: '_blank',
rel: 'noopener',
},
});
function renderMarkdown(content: string) {
try {
return md.render(content);
} catch (e) {
console.error(`Error parsing markdown content ${content}`);
return `<p>${t('assistantChat.errorParsingMarkdown')}</p>`;
}
}
return {
renderMarkdown,
};
}

View File

@@ -0,0 +1,71 @@
<script setup lang="ts">
import AssistantLoadingMessage from '@n8n/design-system/components/AskAssistantLoadingMessage/AssistantLoadingMessage.vue';
import type { ChatUI } from '../../../../types/assistant';
import BaseMessage from '../BaseMessage.vue';
interface Props {
message: ChatUI.AssistantMessage;
isFirstOfRole: boolean;
user?: {
firstName: string;
lastName: string;
};
nextStep?: string;
}
defineProps<Props>();
</script>
<template>
<BaseMessage :message="message" :is-first-of-role="isFirstOfRole" :user="user">
<div :class="$style.workflowMessage">
<div :class="$style.message">
<slot name="icon"></slot>
<div :class="$style.content">
<div v-if="$slots.title" :class="$style.title">
<slot name="title"></slot>
</div>
<div :class="$style.details">
<slot></slot>
</div>
</div>
</div>
<AssistantLoadingMessage v-if="nextStep" :message="nextStep" :class="$style.nextStep" />
</div>
</BaseMessage>
</template>
<style lang="scss" module>
.workflowMessage {
display: flex;
flex-direction: column;
}
.message {
display: flex;
gap: var(--spacing-s);
padding: 0 var(--spacing-2xs) var(--spacing-2xs) 0;
background-color: var(--color-background-light);
border-radius: var(--border-radius-base);
}
.nextStep {
flex: 1;
width: 100%;
}
.content {
flex: 1;
}
.title {
font-weight: var(--font-weight-medium);
margin-bottom: var(--spacing-3xs);
}
.details {
color: var(--color-text-base);
font-size: var(--font-size-2xs);
}
</style>

View File

@@ -0,0 +1,54 @@
<script setup lang="ts">
import { useI18n } from '@n8n/design-system/composables/useI18n';
import BaseWorkflowMessage from './BaseWorkflowMessage.vue';
import type { ChatUI } from '../../../../types/assistant';
interface Props {
message: ChatUI.WorkflowComposedMessage & { id: string; read: boolean };
isFirstOfRole: boolean;
user?: {
firstName: string;
lastName: string;
};
}
const { t } = useI18n();
defineProps<Props>();
</script>
<template>
<BaseWorkflowMessage
:message="message"
:is-first-of-role="isFirstOfRole"
:user="user"
:next-step="t('assistantChat.builder.generatingFinalWorkflow')"
>
<template #title>{{ t('assistantChat.builder.configuredNodes') }}</template>
<ol :class="$style.nodesList">
<li v-for="node in message.nodes" :key="node.name" :class="$style.node">
<div :class="$style.nodeName">{{ node.name }}</div>
</li>
</ol>
</BaseWorkflowMessage>
</template>
<style lang="scss" module>
.nodesList {
display: flex;
flex-direction: column;
gap: var(--spacing-3xs);
list-style-position: outside;
margin: 0;
padding: 0 0 0 var(--spacing-s);
li {
color: var(--color-text-base);
line-height: var(--font-line-height-loose);
}
}
.nodeType {
font-size: var(--font-size-3xs);
color: var(--color-text-light);
}
</style>

View File

@@ -0,0 +1,122 @@
<script setup lang="ts">
import { ref } from 'vue';
import { useI18n } from '@n8n/design-system/composables/useI18n';
import BaseWorkflowMessage from './BaseWorkflowMessage.vue';
import type { ChatUI } from '../../../../types/assistant';
interface Props {
message: ChatUI.RateWorkflowMessage & { id: string; read: boolean };
isFirstOfRole: boolean;
user?: {
firstName: string;
lastName: string;
};
}
const { t } = useI18n();
const emit = defineEmits<{
thumbsUp: [];
thumbsDown: [];
submitFeedback: [string];
}>();
defineProps<Props>();
const feedback = ref('');
const showFeedback = ref(false);
const showSuccess = ref(false);
function onRateButton(rating: 'thumbsUp' | 'thumbsDown') {
showFeedback.value = true;
if (rating === 'thumbsUp') {
emit('thumbsUp');
} else {
emit('thumbsDown');
}
}
function onSubmitFeedback() {
emit('submitFeedback', feedback.value);
showFeedback.value = false;
showSuccess.value = true;
}
</script>
<template>
<BaseWorkflowMessage :message="message" :is-first-of-role="isFirstOfRole" :user="user">
<div :class="$style.content">
<p v-if="!showSuccess">{{ message.content }}</p>
<div v-if="!showFeedback && !showSuccess" :class="$style.buttons">
<n8n-button
type="secondary"
size="small"
:label="t('assistantChat.builder.thumbsUp')"
data-test-id="message-thumbs-up-button"
icon="thumbs-up"
@click="onRateButton('thumbsUp')"
/>
<n8n-button
type="secondary"
size="small"
data-test-id="message-thumbs-down-button"
:label="t('assistantChat.builder.thumbsDown')"
icon="thumbs-down"
@click="onRateButton('thumbsDown')"
/>
</div>
<div v-if="showFeedback" :class="$style.feedbackTextArea">
<n8n-input
v-model="feedback"
:class="$style.feedbackInput"
type="textarea"
:placeholder="t('assistantChat.builder.feedbackPlaceholder')"
data-test-id="message-feedback-input"
:read-only="false"
resize="none"
:rows="5"
/>
<div :class="$style.feedbackTextArea__footer">
<n8n-button
native-type="submit"
type="secondary"
size="small"
data-test-id="message-submit-feedback-button"
@click="onSubmitFeedback"
>
{{ t('assistantChat.builder.submit') }}
</n8n-button>
</div>
</div>
<p v-if="showSuccess" :class="$style.success">{{ t('assistantChat.builder.success') }}</p>
</div>
</BaseWorkflowMessage>
</template>
<style lang="scss" module>
.content {
display: flex;
flex-direction: column;
gap: var(--spacing-2xs);
}
.buttons {
display: flex;
gap: var(--spacing-2xs);
}
.feedbackTextArea {
display: flex;
flex-direction: column;
gap: var(--spacing-2xs);
:global(.el-textarea__inner) {
resize: none;
font-family: var(--font-family);
font-size: var(--font-size-2xs);
}
}
.feedbackTextArea__footer {
display: flex;
justify-content: flex-end;
}
</style>

View File

@@ -0,0 +1,31 @@
<script setup lang="ts">
import { useI18n } from '@n8n/design-system/composables/useI18n';
import BaseWorkflowMessage from './BaseWorkflowMessage.vue';
import type { ChatUI } from '../../../../types/assistant';
interface Props {
message: ChatUI.WorkflowGeneratedMessage & { id: string; read: boolean };
isFirstOfRole: boolean;
user?: {
firstName: string;
lastName: string;
};
}
defineProps<Props>();
const { t } = useI18n();
</script>
<template>
<BaseWorkflowMessage :message="message" :is-first-of-role="isFirstOfRole" :user="user">
<p>{{ t('assistantChat.builder.workflowGenerated1') }}</p>
<p>{{ t('assistantChat.builder.workflowGenerated2') }}</p>
</BaseWorkflowMessage>
</template>
<style lang="scss" module>
.code {
white-space: pre-wrap;
}
</style>

View File

@@ -0,0 +1,48 @@
<script setup lang="ts">
import { useI18n } from '@n8n/design-system/composables/useI18n';
import BaseWorkflowMessage from './BaseWorkflowMessage.vue';
import type { ChatUI } from '../../../../types/assistant';
interface Props {
message: ChatUI.GeneratedNodesMessage & { id: string; read: boolean };
isFirstOfRole: boolean;
user?: {
firstName: string;
lastName: string;
};
}
const { t } = useI18n();
defineProps<Props>();
</script>
<template>
<BaseWorkflowMessage
:message="message"
:is-first-of-role="isFirstOfRole"
:user="user"
:next-step="t('assistantChat.builder.configuringNodes')"
>
<template #title>{{ t('assistantChat.builder.selectedNodes') }}</template>
<ol :class="$style.nodesList">
<li v-for="node in message.nodes" :key="node">{{ node }}</li>
</ol>
</BaseWorkflowMessage>
</template>
<style lang="scss" module>
.nodesList {
display: flex;
flex-direction: column;
gap: var(--spacing-3xs);
list-style-position: outside;
margin: 0;
padding-left: var(--spacing-s);
li {
color: var(--color-text-base);
line-height: var(--font-line-height-loose);
}
}
</style>

View File

@@ -0,0 +1,49 @@
<script setup lang="ts">
import { useI18n } from '@n8n/design-system/composables/useI18n';
import BaseWorkflowMessage from './BaseWorkflowMessage.vue';
import type { ChatUI } from '../../../../types/assistant';
interface Props {
message: ChatUI.WorkflowStepMessage & { id: string; read: boolean };
isFirstOfRole: boolean;
user?: {
firstName: string;
lastName: string;
};
}
defineProps<Props>();
const { t } = useI18n();
</script>
<template>
<BaseWorkflowMessage
:message="message"
:is-first-of-role="isFirstOfRole"
:user="user"
:next-step="t('assistantChat.builder.selectingNodes')"
>
<template #title>{{ t('assistantChat.builder.generatedNodes') }}</template>
<ol :class="$style.stepsList">
<li v-for="step in message.steps" :key="step">{{ step }}</li>
</ol>
</BaseWorkflowMessage>
</template>
<style lang="scss" module>
.stepsList {
display: flex;
flex-direction: column;
gap: var(--spacing-3xs);
list-style-position: outside;
margin: 0;
padding: 0 0 0 var(--spacing-s);
li {
color: var(--color-text-base);
line-height: var(--font-line-height-loose);
}
}
</style>

View File

@@ -28,12 +28,13 @@ withDefaults(
<style module lang="scss">
.container {
display: flex;
align-items: center;
gap: var(--spacing-3xs);
user-select: none;
}
.avatar {
height: var(--spacing-s);
height: var(--spacing-m);
animation: pulse 1.5s infinite;
position: relative;
}
@@ -43,12 +44,15 @@ withDefaults(
position: relative;
overflow: hidden;
line-height: 1.4rem;
height: var(--spacing-xl);
align-items: center;
}
.message {
margin: 0;
padding: 0;
font-weight: var(--font-weight-bold);
font-size: var(--font-size-2xs);
color: var(--color-text-base);
text-align: left;
}

View File

@@ -31,6 +31,20 @@ export default {
'codeDiff.undo': 'Undo',
'betaTag.beta': 'beta',
'askAssistantButton.askAssistant': 'Ask Assistant',
'assistantChat.builder.name': 'AI Builder',
'assistantChat.builder.generatingFinalWorkflow': 'Generating final workflow...',
'assistantChat.builder.configuredNodes': 'Configured nodes',
'assistantChat.builder.thumbsUp': 'Helpful',
'assistantChat.builder.thumbsDown': 'Not helpful',
'assistantChat.builder.feedbackPlaceholder': 'Tell us about your experience',
'assistantChat.builder.success': 'Thank you for your feedback!',
'assistantChat.builder.submit': 'Submit feedback',
'assistantChat.builder.workflowGenerated1': 'Your workflow was created successfully!',
'assistantChat.builder.workflowGenerated2': 'Fix any missing credentials before testing it.',
'assistantChat.builder.configuringNodes': 'Configuring nodes...',
'assistantChat.builder.selectedNodes': 'Selected workflow nodes',
'assistantChat.builder.selectingNodes': 'Selecting nodes...',
'assistantChat.builder.generatedNodes': 'Generated workflow nodes',
'assistantChat.errorParsingMarkdown': 'Error parsing markdown content',
'assistantChat.aiAssistantLabel': 'AI Assistant',
'assistantChat.aiAssistantName': 'Assistant',

View File

@@ -13,7 +13,7 @@ export namespace ChatUI {
content: string;
}
interface CodeDiffMessage {
export interface CodeDiffMessage {
role: 'assistant';
type: 'code-diff';
description?: string;
@@ -24,12 +24,41 @@ export namespace ChatUI {
suggestionId: string;
}
interface EndSessionMessage {
export interface EndSessionMessage {
role: 'assistant';
type: 'event';
eventName: 'end-session';
}
export interface SessionTimeoutMessage {
role: 'assistant';
type: 'event';
eventName: 'session-timeout';
}
export interface SessionErrorMessage {
role: 'assistant';
type: 'event';
eventName: 'session-error';
}
export interface GeneratedNodesMessage {
role: 'assistant';
type: 'workflow-node';
nodes: string[];
}
export interface ComposedNodesMessage {
role: 'assistant';
type: 'workflow-composed';
nodes: Array<{
parameters: Record<string, unknown>;
type: string;
name: string;
position: [number, number];
}>;
}
export interface QuickReply {
type: string;
text: string;
@@ -51,6 +80,39 @@ export namespace ChatUI {
suggestionId: string;
}
export interface WorkflowStepMessage {
role: 'assistant';
type: 'workflow-step';
steps: string[];
}
export interface WorkflowNodeMessage {
role: 'assistant';
type: 'workflow-node';
nodes: string[];
}
export interface WorkflowComposedMessage {
role: 'assistant';
type: 'workflow-composed';
nodes: Array<{
parameters: Record<string, unknown>;
type: string;
name: string;
position: [number, number];
}>;
}
export interface WorkflowGeneratedMessage {
role: 'assistant';
type: 'workflow-generated';
codeSnippet: string;
}
export interface RateWorkflowMessage {
role: 'assistant';
type: 'rate-workflow';
content: string;
}
type MessagesWithReplies = (
| TextMessage
| CodeDiffMessage
@@ -64,7 +126,14 @@ export namespace ChatUI {
| MessagesWithReplies
| ErrorMessage
| EndSessionMessage
| SessionTimeoutMessage
| SessionErrorMessage
| AgentSuggestionMessage
| WorkflowStepMessage
| WorkflowNodeMessage
| WorkflowComposedMessage
| WorkflowGeneratedMessage
| RateWorkflowMessage
) & {
id: string;
read: boolean;

View File

@@ -5,14 +5,15 @@ import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { useRoute } from 'vue-router';
import LoadingView from '@/views/LoadingView.vue';
import BannerStack from '@/components/banners/BannerStack.vue';
import AskAssistantChat from '@/components/AskAssistant/AskAssistantChat.vue';
import Modals from '@/components/Modals.vue';
import Telemetry from '@/components/Telemetry.vue';
import AskAssistantFloatingButton from '@/components/AskAssistant/AskAssistantFloatingButton.vue';
import AskAssistantFloatingButton from '@/components/AskAssistant/Chat/AskAssistantFloatingButton.vue';
import AssistantsHub from '@/components/AskAssistant/AssistantsHub.vue';
import { loadLanguage } from '@/plugins/i18n';
import { APP_MODALS_ELEMENT_ID, HIRING_BANNER, VIEWS } from '@/constants';
import { useRootStore } from '@/stores/root.store';
import { useAssistantStore } from '@/stores/assistant.store';
import { useBuilderStore } from '@/stores/builder.store';
import { useUIStore } from '@/stores/ui.store';
import { useUsersStore } from '@/stores/users.store';
import { useSettingsStore } from '@/stores/settings.store';
@@ -22,6 +23,7 @@ import { useStyles } from './composables/useStyles';
const route = useRoute();
const rootStore = useRootStore();
const assistantStore = useAssistantStore();
const builderStore = useBuilderStore();
const uiStore = useUIStore();
const usersStore = useUsersStore();
const settingsStore = useSettingsStore();
@@ -39,6 +41,7 @@ const hasContentFooter = ref(false);
const appGrid = ref<Element | null>(null);
const assistantSidebarWidth = computed(() => assistantStore.chatWidth);
const builderSidebarWidth = computed(() => builderStore.chatWidth);
onMounted(async () => {
setAppZIndexes();
@@ -65,9 +68,8 @@ const updateGridWidth = async () => {
uiStore.appGridDimensions = { width, height };
}
};
// As assistant sidebar width changes, recalculate the total width regularly
watch(assistantSidebarWidth, async () => {
watch([assistantSidebarWidth, builderSidebarWidth], async () => {
await updateGridWidth();
});
@@ -121,7 +123,7 @@ watch(defaultLocale, (newLocale) => {
<Telemetry />
<AskAssistantFloatingButton v-if="showAssistantButton" />
</div>
<AskAssistantChat />
<AssistantsHub />
</div>
</template>

View File

@@ -6,6 +6,23 @@ import { makeRestApiRequest, streamRequest } from '@/utils/apiUtils';
import { getObjectSizeInKB } from '@/utils/objectUtils';
import type { IDataObject } from 'n8n-workflow';
export function chatWithBuilder(
ctx: IRestApiContext,
payload: ChatRequest.RequestPayload,
onMessageUpdated: (data: ChatRequest.ResponsePayload) => void,
onDone: () => void,
onError: (e: Error) => void,
): void {
void streamRequest<ChatRequest.ResponsePayload>(
ctx,
'/ai/build',
payload,
onMessageUpdated,
onDone,
onError,
);
}
export function chatWithAssistant(
ctx: IRestApiContext,
payload: ChatRequest.RequestPayload,

View File

@@ -0,0 +1,195 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createComponentRenderer } from '@/__tests__/render';
import { setActivePinia } from 'pinia';
import { createTestingPinia } from '@pinia/testing';
import { flushPromises } from '@vue/test-utils';
import { fireEvent } from '@testing-library/vue';
import { faker } from '@faker-js/faker';
import AskAssistantBuild from './AskAssistantBuild.vue';
import { useBuilderStore } from '@/stores/builder.store';
import { mockedStore } from '@/__tests__/utils';
import { STORES } from '@/constants';
vi.mock('@/event-bus', () => ({
nodeViewEventBus: {
emit: vi.fn(),
},
}));
// Mock telemetry
const trackMock = vi.fn();
vi.mock('@/composables/useTelemetry', () => ({
useTelemetry: () => ({
track: trackMock,
}),
}));
// Mock i18n
vi.mock('@/composables/useI18n', () => ({
useI18n: () => ({
baseText: (key: string) => key,
}),
}));
describe('AskAssistantBuild', () => {
const sessionId = faker.string.uuid();
const renderComponent = createComponentRenderer(AskAssistantBuild);
let builderStore: ReturnType<typeof mockedStore<typeof useBuilderStore>>;
beforeEach(() => {
vi.clearAllMocks();
const pinia = createTestingPinia({
initialState: {
[STORES.BUILDER]: {
chatMessages: [],
currentSessionId: sessionId,
streaming: false,
assistantThinkingMessage: undefined,
workflowPrompt: 'Create a workflow',
},
},
});
setActivePinia(pinia);
builderStore = mockedStore(useBuilderStore);
// Mock action implementations
builderStore.initBuilderChat = vi.fn();
builderStore.resetBuilderChat = vi.fn();
builderStore.addAssistantMessages = vi.fn();
builderStore.$onAction = vi.fn().mockReturnValue(vi.fn());
});
describe('rendering', () => {
it('should render component correctly', () => {
const { getByTestId } = renderComponent();
expect(getByTestId('ask-assistant-chat')).toBeInTheDocument();
});
it('should pass correct props to AskAssistantChat component', () => {
renderComponent();
// Basic verification that no methods were called on mount
expect(builderStore.initBuilderChat).not.toHaveBeenCalled();
expect(builderStore.addAssistantMessages).not.toHaveBeenCalled();
});
});
describe('user message handling', () => {
it('should initialize builder chat when a user sends a message', async () => {
const { getByTestId } = renderComponent();
const testMessage = 'Create a workflow to send emails';
// Type message into the chat input
const chatInput = getByTestId('chat-input');
await fireEvent.update(chatInput, testMessage);
// Click the send button
const sendButton = getByTestId('send-message-button');
sendButton.click();
await flushPromises();
expect(builderStore.initBuilderChat).toHaveBeenCalledWith(testMessage, 'chat');
});
});
describe('feedback handling', () => {
beforeEach(() => {
builderStore.chatMessages = [
{
id: faker.string.uuid(),
role: 'assistant',
type: 'workflow-generated',
read: true,
codeSnippet: '{}',
},
{
id: faker.string.uuid(),
role: 'assistant',
type: 'rate-workflow',
read: true,
content: '',
},
];
});
it('should track feedback when user rates the workflow positively', async () => {
const { findByTestId } = renderComponent();
// Find thumbs up button in RateWorkflowMessage component
const thumbsUpButton = await findByTestId('message-thumbs-up-button');
thumbsUpButton.click();
await flushPromises();
expect(trackMock).toHaveBeenCalledWith('User rated workflow generation', {
chat_session_id: sessionId,
helpful: true,
});
});
it('should track feedback when user rates the workflow negatively', async () => {
const { findByTestId } = renderComponent();
// Find thumbs down button in RateWorkflowMessage component
const thumbsDownButton = await findByTestId('message-thumbs-down-button');
thumbsDownButton.click();
await flushPromises();
expect(trackMock).toHaveBeenCalledWith('User rated workflow generation', {
chat_session_id: sessionId,
helpful: false,
});
});
it('should track text feedback when submitted', async () => {
const { findByTestId } = renderComponent();
const feedbackText = 'This workflow is great but could be improved';
// Click thumbs down to show feedback form
const thumbsDownButton = await findByTestId('message-thumbs-down-button');
thumbsDownButton.click();
await flushPromises();
// Type feedback and submit
const feedbackInput = await findByTestId('message-feedback-input');
await fireEvent.update(feedbackInput, feedbackText);
const submitButton = await findByTestId('message-submit-feedback-button');
submitButton.click();
await flushPromises();
expect(trackMock).toHaveBeenCalledWith(
'User submitted workflow generation feedback',
expect.objectContaining({
chat_session_id: sessionId,
feedback: feedbackText,
}),
);
});
});
describe('new workflow generation', () => {
it('should unsubscribe from store actions on unmount', async () => {
const unsubscribeMock = vi.fn();
builderStore.$onAction = vi.fn().mockReturnValue(unsubscribeMock);
const { unmount } = renderComponent();
// Unmount component
unmount();
// Should unsubscribe when component is unmounted
expect(unsubscribeMock).toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,230 @@
<script lang="ts" setup>
import { useBuilderStore } from '@/stores/builder.store';
import { useUsersStore } from '@/stores/users.store';
import { computed, watch, ref, onBeforeUnmount } from 'vue';
import AskAssistantChat from '@n8n/design-system/components/AskAssistantChat/AskAssistantChat.vue';
import { useTelemetry } from '@/composables/useTelemetry';
import type { IWorkflowDataUpdate } from '@/Interface';
import { nodeViewEventBus } from '@/event-bus';
import { v4 as uuid } from 'uuid';
import { useI18n } from '@/composables/useI18n';
import { STICKY_NODE_TYPE } from '@/constants';
const emit = defineEmits<{
close: [];
}>();
const builderStore = useBuilderStore();
const usersStore = useUsersStore();
const telemetry = useTelemetry();
const i18n = useI18n();
const helpful = ref(false);
const generationStartTime = ref(0);
const user = computed(() => ({
firstName: usersStore.currentUser?.firstName ?? '',
lastName: usersStore.currentUser?.lastName ?? '',
}));
const workflowGenerated = ref(false);
const loadingMessage = computed(() => builderStore.assistantThinkingMessage);
async function onUserMessage(content: string) {
// If there is no current session running, initialize the support chat session
await builderStore.initBuilderChat(content, 'chat');
}
function fixWorkflowStickiesPosition(workflowData: IWorkflowDataUpdate): IWorkflowDataUpdate {
const STICKY_WIDTH = 480;
const HEADERS_HEIGHT = 40;
const NEW_LINE_HEIGHT = 20;
const CHARACTER_WIDTH = 65;
const NODE_WIDTH = 100;
const stickyNodes = workflowData.nodes?.filter((node) => node.type === STICKY_NODE_TYPE);
const nonStickyNodes = workflowData.nodes?.filter((node) => node.type !== STICKY_NODE_TYPE);
const fixedStickies = stickyNodes?.map((node, index) => {
const content = node.parameters.content?.toString() ?? '';
const newLines = content.match(/\n/g) ?? [];
// Match any markdown heading from # to ###### at the start of a line
const headings = content.match(/^#{1,6} /gm) ?? [];
const headingHeight = headings.length * HEADERS_HEIGHT;
const newLinesHeight = newLines.length * NEW_LINE_HEIGHT;
const contentHeight = (content.length / CHARACTER_WIDTH) * NEW_LINE_HEIGHT;
const height = Math.ceil(headingHeight + newLinesHeight + contentHeight) + NEW_LINE_HEIGHT;
const firstNode = nonStickyNodes?.[0];
const xPos = (firstNode?.position[0] ?? 0) + index * (STICKY_WIDTH + NODE_WIDTH);
return {
...node,
parameters: {
...node.parameters,
height,
width: STICKY_WIDTH,
},
position: [xPos, -1 * (height + 50)] as [number, number],
};
});
return {
...workflowData,
nodes: [...(nonStickyNodes ?? []), ...(fixedStickies ?? [])],
};
}
function onInsertWorkflow(code: string) {
let workflowData: IWorkflowDataUpdate;
try {
workflowData = JSON.parse(code);
} catch (error) {
console.error('Error parsing workflow data', error);
return;
}
telemetry.track('Workflow generated from prompt', {
prompt: builderStore.workflowPrompt,
latency: new Date().getTime() - generationStartTime.value,
workflow_json: code,
});
nodeViewEventBus.emit('importWorkflowData', {
data: fixWorkflowStickiesPosition(workflowData),
tidyUp: true,
});
workflowGenerated.value = true;
builderStore.addAssistantMessages(
[
{
type: 'rate-workflow',
content: i18n.baseText('aiAssistant.builder.feedbackPrompt'),
role: 'assistant',
},
],
uuid(),
);
}
function onNewWorkflow() {
builderStore.resetBuilderChat();
workflowGenerated.value = false;
helpful.value = false;
generationStartTime.value = new Date().getTime();
}
function onThumbsUp() {
helpful.value = true;
telemetry.track('User rated workflow generation', {
chat_session_id: builderStore.currentSessionId,
helpful: helpful.value,
});
}
function onThumbsDown() {
helpful.value = false;
telemetry.track('User rated workflow generation', {
chat_session_id: builderStore.currentSessionId,
helpful: helpful.value,
});
}
function onSubmitFeedback(feedback: string) {
telemetry.track('User submitted workflow generation feedback', {
chat_session_id: builderStore.currentSessionId,
helpful: helpful.value,
feedback,
});
}
watch(
() => builderStore.chatMessages,
(messages) => {
if (workflowGenerated.value) return;
const workflowGeneratedMessage = messages.find((msg) => msg.type === 'workflow-generated');
if (workflowGeneratedMessage) {
onInsertWorkflow(workflowGeneratedMessage.codeSnippet);
}
},
{ deep: true },
);
const unsubscribe = builderStore.$onAction(({ name }) => {
if (name === 'initBuilderChat') {
onNewWorkflow();
}
});
onBeforeUnmount(() => {
unsubscribe();
});
</script>
<template>
<div data-test-id="ask-assistant-chat" tabindex="0" :class="$style.container" @keydown.stop>
<AskAssistantChat
:user="user"
:messages="builderStore.chatMessages"
:streaming="builderStore.streaming"
:loading-message="loadingMessage"
:session-id="builderStore.currentSessionId"
:mode="i18n.baseText('aiAssistant.builder.mode')"
:title="'n8n AI'"
:placeholder="i18n.baseText('aiAssistant.builder.placeholder')"
@close="emit('close')"
@message="onUserMessage"
@thumbs-up="onThumbsUp"
@thumbs-down="onThumbsDown"
@submit-feedback="onSubmitFeedback"
@insert-workflow="onInsertWorkflow"
>
<template #header>
<slot name="header" />
</template>
<template #placeholder>
<n8n-text :class="$style.topText">{{
i18n.baseText('aiAssistant.builder.placeholder')
}}</n8n-text>
</template>
<template v-if="workflowGenerated" #inputPlaceholder>
<div :class="$style.newWorkflowButtonWrapper">
<n8n-button
type="secondary"
size="small"
:class="$style.newWorkflowButton"
@click="onNewWorkflow"
>
{{ i18n.baseText('aiAssistant.builder.generateNew') }}
</n8n-button>
<n8n-text :class="$style.newWorkflowText">
{{ i18n.baseText('aiAssistant.builder.newWorkflowNotice') }}
</n8n-text>
</div>
</template>
</AskAssistantChat>
</div>
</template>
<style lang="scss" module>
.container {
height: 100%;
width: 100%;
}
.topText {
color: var(--color-text-base);
}
.newWorkflowButtonWrapper {
display: flex;
flex-direction: column;
flex-flow: wrap;
gap: var(--spacing-2xs);
background-color: var(--color-background-light);
padding: var(--spacing-xs);
border: 0;
}
.newWorkflowText {
color: var(--color-text-base);
font-size: var(--font-size-2xs);
}
</style>

View File

@@ -0,0 +1,102 @@
<script lang="ts" setup>
import { useBuilderStore } from '@/stores/builder.store';
import { useAssistantStore } from '@/stores/assistant.store';
import { useDebounce } from '@/composables/useDebounce';
import { computed, onBeforeUnmount, ref } from 'vue';
import SlideTransition from '@/components/transitions/SlideTransition.vue';
import AskAssistantBuild from './Agent/AskAssistantBuild.vue';
import AskAssistantChat from './Chat/AskAssistantChat.vue';
const builderStore = useBuilderStore();
const assistantStore = useAssistantStore();
const isBuildMode = ref(builderStore.isAIBuilderEnabled);
const chatWidth = computed(() => {
return isBuildMode.value ? builderStore.chatWidth : assistantStore.chatWidth;
});
function onResize(data: { direction: string; x: number; width: number }) {
builderStore.updateWindowWidth(data.width);
assistantStore.updateWindowWidth(data.width);
}
function onResizeDebounced(data: { direction: string; x: number; width: number }) {
void useDebounce().callDebounced(onResize, { debounceTime: 10, trailing: true }, data);
}
function toggleAssistantMode() {
isBuildMode.value = !isBuildMode.value;
if (isBuildMode.value) {
builderStore.openChat();
} else {
assistantStore.openChat();
}
}
function onClose() {
builderStore.closeChat();
assistantStore.closeChat();
}
const unsubscribeAssistantStore = assistantStore.$onAction(({ name }) => {
// When assistant is opened from error or credentials help
// switch from build mode to chat mode
if (['initErrorHelper', 'initCredHelp', 'openChat'].includes(name)) {
isBuildMode.value = false;
}
});
const unsubscribeBuilderStore = builderStore.$onAction(({ name }) => {
// When assistant is opened from error or credentials help
// switch from build mode to chat mode
if (name === 'initBuilderChat') {
isBuildMode.value = true;
}
});
onBeforeUnmount(() => {
unsubscribeAssistantStore();
unsubscribeBuilderStore();
});
</script>
<template>
<SlideTransition>
<N8nResizeWrapper
v-show="builderStore.isAssistantOpen || assistantStore.isAssistantOpen"
:supported-directions="['left']"
:width="chatWidth"
data-test-id="ask-assistant-sidebar"
@resize="onResizeDebounced"
>
<div :style="{ width: `${chatWidth}px` }" :class="$style.wrapper">
<div :class="$style.assistantContent">
<AskAssistantBuild v-if="isBuildMode" @close="onClose">
<template #header>
<HubSwitcher :is-build-mode="isBuildMode" @toggle="toggleAssistantMode" />
</template>
</AskAssistantBuild>
<AskAssistantChat v-else @close="onClose">
<!-- Header switcher is only visible when AIBuilder is enabled -->
<template v-if="builderStore.isAIBuilderEnabled" #header>
<HubSwitcher :is-build-mode="isBuildMode" @toggle="toggleAssistantMode" />
</template>
</AskAssistantChat>
</div>
</div>
</N8nResizeWrapper>
</SlideTransition>
</template>
<style lang="scss" module>
.wrapper {
height: 100%;
display: flex;
flex-direction: column;
}
.assistantContent {
flex: 1;
overflow: hidden;
}
</style>

View File

@@ -1,15 +1,21 @@
<script lang="ts" setup>
import { useAssistantStore } from '@/stores/assistant.store';
import { useDebounce } from '@/composables/useDebounce';
import { useUsersStore } from '@/stores/users.store';
import { computed } from 'vue';
import SlideTransition from '@/components/transitions/SlideTransition.vue';
import AskAssistantChat from '@n8n/design-system/components/AskAssistantChat/AskAssistantChat.vue';
import { useTelemetry } from '@/composables/useTelemetry';
import { useBuilderStore } from '@/stores/builder.store';
import { useI18n } from '@/composables/useI18n';
const emit = defineEmits<{
close: [];
}>();
const assistantStore = useAssistantStore();
const usersStore = useUsersStore();
const telemetry = useTelemetry();
const builderStore = useBuilderStore();
const i18n = useI18n();
const user = computed(() => ({
firstName: usersStore.currentUser?.firstName ?? '',
@@ -18,14 +24,6 @@ const user = computed(() => ({
const loadingMessage = computed(() => assistantStore.assistantThinkingMessage);
function onResize(data: { direction: string; x: number; width: number }) {
assistantStore.updateWindowWidth(data.width);
}
function onResizeDebounced(data: { direction: string; x: number; width: number }) {
void useDebounce().callDebounced(onResize, { debounceTime: 10, trailing: true }, data);
}
async function onUserMessage(content: string, quickReplyType?: string, isFeedback = false) {
// If there is no current session running, initialize the support chat session
if (!assistantStore.currentSessionId) {
@@ -62,47 +60,36 @@ async function undoCodeDiff(index: number) {
action: 'undo_code_replace',
});
}
function onClose() {
assistantStore.closeChat();
telemetry.track('User closed assistant', { source: 'top-toggle' });
}
</script>
<template>
<SlideTransition>
<N8nResizeWrapper
v-show="assistantStore.isAssistantOpen"
:supported-directions="['left']"
:width="assistantStore.chatWidth"
data-test-id="ask-assistant-sidebar"
@resize="onResizeDebounced"
<div data-test-id="ask-assistant-chat" tabindex="0" class="wrapper" @keydown.stop>
<AskAssistantChat
:user="user"
:messages="assistantStore.chatMessages"
:streaming="assistantStore.streaming"
:loading-message="loadingMessage"
:session-id="assistantStore.currentSessionId"
:title="
builderStore.isAIBuilderEnabled
? i18n.baseText('aiAssistant.n8nAi')
: i18n.baseText('aiAssistant.assistant')
"
@close="emit('close')"
@message="onUserMessage"
@code-replace="onCodeReplace"
@code-undo="undoCodeDiff"
>
<div
:style="{ width: `${assistantStore.chatWidth}px` }"
:class="$style.wrapper"
data-test-id="ask-assistant-chat"
tabindex="0"
@keydown.stop
>
<AskAssistantChat
:user="user"
:messages="assistantStore.chatMessages"
:streaming="assistantStore.streaming"
:loading-message="loadingMessage"
:session-id="assistantStore.currentSessionId"
@close="onClose"
@message="onUserMessage"
@code-replace="onCodeReplace"
@code-undo="undoCodeDiff"
/>
</div>
</N8nResizeWrapper>
</SlideTransition>
<template #header>
<slot name="header" />
</template>
</AskAssistantChat>
</div>
</template>
<style module>
<style scoped>
.wrapper {
height: 100%;
width: 100%;
}
</style>

View File

@@ -3,7 +3,6 @@ import { useI18n } from '@/composables/useI18n';
import { useStyles } from '@/composables/useStyles';
import { useAssistantStore } from '@/stores/assistant.store';
import { useCanvasStore } from '@/stores/canvas.store';
import { useNDVStore } from '@/stores/ndv.store';
import AssistantAvatar from '@n8n/design-system/components/AskAssistantAvatar/AssistantAvatar.vue';
import AskAssistantButton from '@n8n/design-system/components/AskAssistantButton/AskAssistantButton.vue';
import { computed } from 'vue';
@@ -12,8 +11,6 @@ const assistantStore = useAssistantStore();
const i18n = useI18n();
const { APP_Z_INDEXES } = useStyles();
const canvasStore = useCanvasStore();
const ndvStore = useNDVStore();
const bottom = computed(() => (ndvStore.activeNode === null ? canvasStore.panelHeight : 0));
const lastUnread = computed(() => {
const msg = assistantStore.lastUnread;
@@ -44,7 +41,7 @@ const onClick = () => {
v-if="assistantStore.canShowAssistantButtonsOnCanvas && !assistantStore.isAssistantOpen"
:class="$style.container"
data-test-id="ask-assistant-floating-button"
:style="{ bottom: `${bottom}px` }"
:style="{ '--canvas-panel-height-offset': `${canvasStore.panelHeight}px` }"
>
<n8n-tooltip
:z-index="APP_Z_INDEXES.ASK_ASSISTANT_FLOATING_BUTTON_TOOLTIP"
@@ -67,8 +64,8 @@ const onClick = () => {
<style lang="scss" module>
.container {
position: absolute;
margin: var(--spacing-s);
right: 0;
bottom: calc(var(--canvas-panel-height-offset, 0px) + var(--spacing-s));
right: var(--spacing-s);
z-index: var(--z-index-ask-assistant-floating-button);
}

View File

@@ -1,6 +1,6 @@
<script lang="ts" setup>
import { NEW_ASSISTANT_SESSION_MODAL } from '@/constants';
import Modal from '../Modal.vue';
import Modal from '@/components/Modal.vue';
import AssistantIcon from '@n8n/design-system/components/AskAssistantIcon/AssistantIcon.vue';
import AssistantText from '@n8n/design-system/components/AskAssistantText/AssistantText.vue';
import { useI18n } from '@/composables/useI18n';

View File

@@ -0,0 +1,32 @@
<script lang="ts" setup>
import { useI18n } from '@/composables/useI18n';
import { computed } from 'vue';
defineProps<{
isBuildMode: boolean;
}>();
const emit = defineEmits<{
toggle: [value: boolean];
}>();
const i18n = useI18n();
const options = computed(() => [
{ label: i18n.baseText('aiAssistant.assistant'), value: false },
{ label: i18n.baseText('aiAssistant.builder.name'), value: true },
]);
function toggle(value: boolean) {
emit('toggle', value);
}
</script>
<template>
<n8n-radio-buttons
size="small"
:model-value="isBuildMode"
:options="options"
@update:model-value="toggle"
/>
</template>

View File

@@ -69,7 +69,7 @@ import DebugPaywallModal from '@/components/DebugPaywallModal.vue';
import WorkflowHistoryVersionRestoreModal from '@/components/WorkflowHistory/WorkflowHistoryVersionRestoreModal.vue';
import SetupWorkflowCredentialsModal from '@/components/SetupWorkflowCredentialsModal/SetupWorkflowCredentialsModal.vue';
import ProjectMoveResourceModal from '@/components/Projects/ProjectMoveResourceModal.vue';
import NewAssistantSessionModal from '@/components/AskAssistant/NewAssistantSessionModal.vue';
import NewAssistantSessionModal from '@/components/AskAssistant/Chat/NewAssistantSessionModal.vue';
import PromptMfaCodeModal from './PromptMfaCodeModal/PromptMfaCodeModal.vue';
import CommunityPlusEnrollmentModal from '@/components/CommunityPlusEnrollmentModal.vue';
import WorkflowActivationConflictingWebhookModal from '@/components/WorkflowActivationConflictingWebhookModal.vue';

View File

@@ -14,6 +14,7 @@ import { useUIStore } from '@/stores/ui.store';
import { DRAG_EVENT_DATA_KEY } from '@/constants';
import { useAssistantStore } from '@/stores/assistant.store';
import N8nIconButton from '@n8n/design-system/components/N8nIconButton/IconButton.vue';
import { useBuilderStore } from '@/stores/builder.store';
export interface Props {
active?: boolean;
@@ -29,6 +30,7 @@ const emit = defineEmits<{
}>();
const uiStore = useUIStore();
const assistantStore = useAssistantStore();
const builderStore = useBuilderStore();
const { setShowScrim, setActions, setMergeNodes } = useNodeCreatorStore();
const { generateMergedNodesAndActions } = useActionsGenerator();
@@ -43,9 +45,21 @@ const showScrim = computed(() => useNodeCreatorStore().showScrim);
const viewStacksLength = computed(() => useViewStacks().viewStacks.length);
const nodeCreatorInlineStyle = computed(() => {
const rightPosition = assistantStore.isAssistantOpen ? assistantStore.chatWidth : 0;
const rightPosition = getRightOffset();
return { top: `${uiStore.bannersHeight + uiStore.headerHeight}px`, right: `${rightPosition}px` };
});
function getRightOffset() {
if (assistantStore.isAssistantOpen) {
return assistantStore.chatWidth;
}
if (builderStore.isAssistantOpen) {
return builderStore.chatWidth;
}
return 0;
}
function onMouseUpOutside() {
if (state.mousedownInsideEvent) {
const clickEvent = new MouseEvent('click', {

View File

@@ -200,7 +200,7 @@ const renameKeyCode = ' ';
useShortKeyPress(
renameKeyCode,
() => {
if (lastSelectedNode.value) {
if (lastSelectedNode.value && lastSelectedNode.value.id !== CanvasNodeRenderType.AIPrompt) {
emit('update:node:name', lastSelectedNode.value.id);
}
},
@@ -296,7 +296,7 @@ const keyMap = computed(() => {
ctrl_alt_n: () => emit('create:workflow'),
ctrl_enter: () => emit('run:workflow'),
ctrl_s: () => emit('save:workflow'),
shift_alt_t: async () => await onTidyUp('keyboard-shortcut'),
shift_alt_t: async () => await onTidyUp({ source: 'keyboard-shortcut' }),
};
return fullKeymap;
});
@@ -658,16 +658,16 @@ async function onContextMenuAction(action: ContextMenuAction, nodeIds: string[])
case 'change_color':
return props.eventBus.emit('nodes:action', { ids: nodeIds, action: 'update:sticky:color' });
case 'tidy_up':
return await onTidyUp('context-menu');
return await onTidyUp({ source: 'context-menu' });
}
}
async function onTidyUp(source: CanvasLayoutSource) {
async function onTidyUp(payload: { source: CanvasLayoutSource }) {
const applyOnSelection = selectedNodes.value.length > 1;
const target = applyOnSelection ? 'selection' : 'all';
const result = layout(target);
emit('tidy-up', { result, target, source });
emit('tidy-up', { result, target, source: payload.source });
if (!applyOnSelection) {
await nextTick();
@@ -749,14 +749,14 @@ const initialized = ref(false);
onMounted(() => {
props.eventBus.on('fitView', onFitView);
props.eventBus.on('nodes:select', onSelectNodes);
props.eventBus.on('tidyUp', onTidyUp);
window.addEventListener('blur', onWindowBlur);
});
onUnmounted(() => {
props.eventBus.off('fitView', onFitView);
props.eventBus.off('nodes:select', onSelectNodes);
props.eventBus.off('tidyUp', onTidyUp);
window.removeEventListener('blur', onWindowBlur);
});
@@ -900,7 +900,7 @@ provide(CanvasKey, {
@zoom-in="onZoomIn"
@zoom-out="onZoomOut"
@reset-zoom="onResetZoom"
@tidy-up="onTidyUp('canvas-button')"
@tidy-up="onTidyUp({ source: 'canvas-button' })"
/>
<Suspense>

View File

@@ -288,7 +288,9 @@ provide(CanvasNodeKey, {
eventBus: canvasNodeEventBus,
});
const hasToolbar = computed(() => props.data.type !== CanvasNodeRenderType.AddNodes);
const hasToolbar = computed(
() => ![CanvasNodeRenderType.AddNodes, CanvasNodeRenderType.AIPrompt].includes(renderType.value),
);
const showToolbar = computed(() => {
const target = contextMenu.target.value;
@@ -392,6 +394,7 @@ onBeforeUnmount(() => {
@move="onMove"
@update="onUpdate"
@open:contextmenu="onOpenContextMenuFromNode"
@delete="onDelete"
/>
<CanvasNodeTrigger

View File

@@ -3,6 +3,7 @@ import { h, inject } from 'vue';
import CanvasNodeDefault from '@/components/canvas/elements/nodes/render-types/CanvasNodeDefault.vue';
import CanvasNodeStickyNote from '@/components/canvas/elements/nodes/render-types/CanvasNodeStickyNote.vue';
import CanvasNodeAddNodes from '@/components/canvas/elements/nodes/render-types/CanvasNodeAddNodes.vue';
import CanvasNodeAIPrompt from '@/components/canvas/elements/nodes/render-types/CanvasNodeAIPrompt.vue';
import { CanvasNodeKey } from '@/constants';
import { CanvasNodeRenderType } from '@/types';
@@ -19,6 +20,9 @@ const Render = () => {
case CanvasNodeRenderType.AddNodes:
Component = CanvasNodeAddNodes;
break;
case CanvasNodeRenderType.AIPrompt:
Component = CanvasNodeAIPrompt;
break;
default:
Component = CanvasNodeDefault;
}

View File

@@ -0,0 +1,131 @@
<script setup lang="ts">
import { ref, computed } from 'vue';
import { useI18n } from '@/composables/useI18n';
import { useCanvasNode } from '@/composables/useCanvasNode';
import { useBuilderStore } from '@/stores/builder.store';
const emit = defineEmits<{
delete: [id: string];
}>();
const i18n = useI18n();
const { id } = useCanvasNode();
const builderStore = useBuilderStore();
const isPromptVisible = ref(true);
const isFocused = ref(false);
const prompt = ref('');
const hasContent = computed(() => prompt.value.trim().length > 0);
async function onSubmit() {
builderStore.openChat();
emit('delete', id.value);
await builderStore.initBuilderChat(prompt.value, 'canvas');
isPromptVisible.value = false;
}
</script>
<template>
<div v-if="isPromptVisible" :class="$style.container" data-test-id="canvas-ai-prompt">
<div :class="[$style.promptContainer, { [$style.focused]: isFocused }]">
<form :class="$style.form" @submit.prevent="onSubmit">
<n8n-input
v-model="prompt"
:class="$style.form_textarea"
type="textarea"
:disabled="builderStore.streaming"
:placeholder="i18n.baseText('aiAssistant.builder.placeholder')"
:read-only="false"
:rows="15"
@focus="isFocused = true"
@blur="isFocused = false"
@keydown.meta.enter.stop="onSubmit"
/>
<div :class="$style.form_footer">
<n8n-button
native-type="submit"
:disabled="!hasContent || builderStore.streaming"
@keydown.enter="onSubmit"
>{{ i18n.baseText('aiAssistant.builder.buildWorkflow') }}</n8n-button
>
</div>
</form>
</div>
<div :class="$style.or">
<p :class="$style.or_text">or</p>
</div>
</div>
</template>
<style lang="scss" module>
.container {
display: flex;
flex-direction: row;
}
.promptContainer {
--width: 620px;
--height: 150px;
display: flex;
flex-direction: column;
gap: var(--spacing-2xs);
width: var(--width);
height: var(--height);
padding: 0;
border: 1px solid var(--color-foreground-dark);
background-color: var(--color-background-xlight);
border-radius: var(--border-radius-base);
overflow: hidden;
&.focused {
border: 1px solid var(--color-primary);
}
}
.form {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
.form_textarea {
display: flex;
flex: 1;
min-height: 0;
overflow: hidden;
border: 0;
:global(.el-textarea__inner) {
height: 100%;
min-height: 0;
overflow-y: auto;
border: 0;
background: transparent;
resize: none;
font-family: var(--font-family);
}
}
.form_footer {
display: flex;
justify-content: flex-end;
padding: var(--spacing-2xs);
}
.or {
display: flex;
justify-content: center;
align-items: center;
width: 60px;
height: 100px;
cursor: auto;
}
.or_text {
font-size: var(--font-size-m);
color: var(--color-text-base);
}
</style>

View File

@@ -13,7 +13,11 @@ import { GRID_SIZE, NODE_SIZE } from '../utils/nodeViewUtils';
export type CanvasLayoutOptions = { id?: string };
export type CanvasLayoutTarget = 'selection' | 'all';
export type CanvasLayoutSource = 'keyboard-shortcut' | 'canvas-button' | 'context-menu';
export type CanvasLayoutSource =
| 'keyboard-shortcut'
| 'canvas-button'
| 'context-menu'
| 'import-workflow-data';
export type CanvasLayoutTargetData = {
nodes: Array<GraphNode<CanvasNodeData>>;
edges: CanvasConnection[];

View File

@@ -15,6 +15,7 @@ import type {
CanvasConnectionPort,
CanvasNode,
CanvasNodeAddNodesRender,
CanvasNodeAIPromptRender,
CanvasNodeData,
CanvasNodeDefaultRender,
CanvasNodeDefaultRenderLabelSize,
@@ -92,6 +93,12 @@ export function useCanvasMapping({
options: {},
};
}
function createAIPromptRenderType(): CanvasNodeAIPromptRender {
return {
type: CanvasNodeRenderType.AIPrompt,
options: {},
};
}
function createDefaultNodeRenderType(node: INodeUi): CanvasNodeDefaultRender {
const nodeType = nodeTypeDescriptionByNodeId.value[node.id];
@@ -130,6 +137,9 @@ export function useCanvasMapping({
case `${CanvasNodeRenderType.AddNodes}`:
acc[node.id] = createAddNodesRenderType();
break;
case `${CanvasNodeRenderType.AIPrompt}`:
acc[node.id] = createAIPromptRenderType();
break;
default:
acc[node.id] = createDefaultNodeRenderType(node);
}

View File

@@ -705,6 +705,7 @@ export const enum STORES {
PUSH = 'push',
COLLABORATION = 'collaboration',
ASSISTANT = 'assistant',
BUILDER = 'builder',
BECOME_TEMPLATE_CREATOR = 'becomeTemplateCreator',
PROJECTS = 'projects',
API_KEYS = 'apiKeys',
@@ -759,11 +760,18 @@ export const SCHEMA_PREVIEW_EXPERIMENT = {
variant: 'variant',
};
export const WORKFLOW_BUILDER_EXPERIMENT = {
name: '30_workflow_builder',
control: 'control',
variant: 'variant',
};
export const EXPERIMENTS_TO_TRACK = [
CREDENTIAL_DOCS_EXPERIMENT.name,
EASY_AI_WORKFLOW_EXPERIMENT.name,
AI_CREDITS_EXPERIMENT.name,
SCHEMA_PREVIEW_EXPERIMENT.name,
WORKFLOW_BUILDER_EXPERIMENT.name,
];
export const WORKFLOW_EVALUATION_EXPERIMENT = '025_workflow_evaluation';

View File

@@ -23,6 +23,9 @@ export interface NodeViewEventBusEvents {
'runWorkflowButton:mouseenter': never;
'runWorkflowButton:mouseleave': never;
/** Command to tidy up the canvas */
tidyUp: never;
}
export const nodeViewEventBus = createEventBus<NodeViewEventBusEvents>();

View File

@@ -160,6 +160,15 @@
"auth.signup.setupYourAccountError": "Problem setting up your account",
"auth.signup.tokenValidationError": "Issue validating invite token",
"aiAssistant.name": "Assistant",
"aiAssistant.n8nAi": "n8n AI",
"aiAssistant.builder.name": "Builder",
"aiAssistant.builder.mode": "AI Builder",
"aiAssistant.builder.placeholder": "What would you like to automate?",
"aiAssistant.builder.generateNew": "Generate new workflow",
"aiAssistant.builder.buildWorkflow": "Build workflow",
"aiAssistant.builder.newWorkflowNotice": "The created workflow will be added to the editor",
"aiAssistant.builder.feedbackPrompt": "Is this workflow helpful?",
"aiAssistant.builder.invalidPrompt": "Prompt validation failed. Please try again with a clearer description of your workflow requirements and supported integrations.",
"aiAssistant.assistant": "AI Assistant",
"aiAssistant.newSessionModal.title.part1": "Start new",
"aiAssistant.newSessionModal.title.part2": "session",

View File

@@ -31,7 +31,7 @@ import { useCredentialsStore } from './credentials.store';
import { useAIAssistantHelpers } from '@/composables/useAIAssistantHelpers';
export const MAX_CHAT_WIDTH = 425;
export const MIN_CHAT_WIDTH = 250;
export const MIN_CHAT_WIDTH = 300;
export const DEFAULT_CHAT_WIDTH = 330;
export const ENABLED_VIEWS = [
...EDITABLE_CANVAS_VIEWS,

View File

@@ -0,0 +1,370 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { setActivePinia, createPinia } from 'pinia';
import { ENABLED_VIEWS, useBuilderStore } from '@/stores/builder.store';
import type { ChatRequest } from '@/types/assistant.types';
import { usePostHog } from './posthog.store';
import { useSettingsStore } from '@/stores/settings.store';
import { defaultSettings } from '../__tests__/defaults';
import { merge } from 'lodash-es';
import { DEFAULT_POSTHOG_SETTINGS } from './posthog.test';
import { WORKFLOW_BUILDER_EXPERIMENT } from '@/constants';
import { reactive } from 'vue';
import * as chatAPI from '@/api/ai';
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';
let settingsStore: ReturnType<typeof useSettingsStore>;
let posthogStore: ReturnType<typeof usePostHog>;
const apiSpy = vi.spyOn(chatAPI, 'chatWithBuilder');
const track = vi.fn();
const spy = vi.spyOn(telemetryModule, 'useTelemetry');
spy.mockImplementation(
() =>
({
track,
}) as unknown as Telemetry,
);
const setAssistantEnabled = (enabled: boolean) => {
settingsStore.setSettings(
merge({}, defaultSettings, {
aiAssistant: { enabled },
}),
);
};
const currentRouteName = ENABLED_VIEWS[0];
vi.mock('vue-router', () => ({
useRoute: vi.fn(() =>
reactive({
path: '/',
params: {},
name: currentRouteName,
}),
),
useRouter: vi.fn(),
RouterLink: vi.fn(),
}));
describe('AI Builder store', () => {
beforeEach(() => {
vi.clearAllMocks();
setActivePinia(createPinia());
settingsStore = useSettingsStore();
settingsStore.setSettings(
merge({}, defaultSettings, {
posthog: DEFAULT_POSTHOG_SETTINGS,
}),
);
window.posthog = {
init: () => {},
identify: () => {},
};
posthogStore = usePostHog();
posthogStore.init();
track.mockReset();
});
it('initializes with default values', () => {
const builderStore = useBuilderStore();
expect(builderStore.chatWidth).toBe(DEFAULT_CHAT_WIDTH);
expect(builderStore.chatMessages).toEqual([]);
expect(builderStore.chatWindowOpen).toBe(false);
expect(builderStore.streaming).toBe(false);
});
it('can change chat width', () => {
const builderStore = useBuilderStore();
builderStore.updateWindowWidth(400);
expect(builderStore.chatWidth).toBe(400);
});
it('should not allow chat width to be less than the minimal width', () => {
const builderStore = useBuilderStore();
builderStore.updateWindowWidth(100);
expect(builderStore.chatWidth).toBe(MIN_CHAT_WIDTH);
});
it('should not allow chat width to be more than the maximal width', () => {
const builderStore = useBuilderStore();
builderStore.updateWindowWidth(2000);
expect(builderStore.chatWidth).toBe(MAX_CHAT_WIDTH);
});
it('should open chat window', () => {
const builderStore = useBuilderStore();
builderStore.openChat();
expect(builderStore.chatWindowOpen).toBe(true);
});
it('should close chat window', () => {
const builderStore = useBuilderStore();
builderStore.closeChat();
expect(builderStore.chatWindowOpen).toBe(false);
});
it('can add a simple assistant message', () => {
const builderStore = useBuilderStore();
const message: ChatRequest.MessageResponse = {
type: 'message',
role: 'assistant',
text: 'Hello!',
};
builderStore.addAssistantMessages([message], '1');
expect(builderStore.chatMessages.length).toBe(1);
expect(builderStore.chatMessages[0]).toEqual({
id: '1',
type: 'text',
role: 'assistant',
content: 'Hello!',
quickReplies: undefined,
read: true, // Builder messages are always read
});
});
it('can add a workflow step message', () => {
const builderStore = useBuilderStore();
const message: ChatRequest.MessageResponse = {
type: 'workflow-step',
role: 'assistant',
steps: ['Step 1', 'Step 2'],
};
builderStore.addAssistantMessages([message], '1');
expect(builderStore.chatMessages.length).toBe(1);
expect(builderStore.chatMessages[0]).toEqual({
id: '1',
type: 'workflow-step',
role: 'assistant',
steps: ['Step 1', 'Step 2'],
read: true,
});
});
it('can add a workflow-generated message', () => {
const builderStore = useBuilderStore();
const message: ChatRequest.MessageResponse = {
type: 'workflow-generated',
role: 'assistant',
codeSnippet: '{"nodes":[],"connections":[]}',
};
builderStore.addAssistantMessages([message], '1');
expect(builderStore.chatMessages.length).toBe(1);
expect(builderStore.chatMessages[0]).toEqual({
id: '1',
type: 'workflow-generated',
role: 'assistant',
codeSnippet: '{"nodes":[],"connections":[]}',
read: true,
});
});
it('can add a rate-workflow message', () => {
const builderStore = useBuilderStore();
const message: ChatRequest.MessageResponse = {
type: 'rate-workflow',
role: 'assistant',
content: 'How was the workflow?',
};
builderStore.addAssistantMessages([message], '1');
expect(builderStore.chatMessages.length).toBe(1);
expect(builderStore.chatMessages[0]).toEqual({
id: '1',
type: 'rate-workflow',
role: 'assistant',
content: 'How was the workflow?',
read: true,
});
});
it('should reset builder chat session', () => {
const builderStore = useBuilderStore();
const message: ChatRequest.MessageResponse = {
type: 'message',
role: 'assistant',
text: 'Hello!',
quickReplies: [
{ text: 'Yes', type: 'text' },
{ text: 'No', type: 'text' },
],
};
builderStore.addAssistantMessages([message], '1');
expect(builderStore.chatMessages.length).toBe(1);
builderStore.resetBuilderChat();
expect(builderStore.chatMessages).toEqual([]);
expect(builderStore.currentSessionId).toBeUndefined();
});
it('should not show builder if disabled in settings', () => {
const builderStore = useBuilderStore();
setAssistantEnabled(false);
expect(builderStore.isAssistantEnabled).toBe(false);
expect(builderStore.canShowAssistant).toBe(false);
expect(builderStore.canShowAssistantButtonsOnCanvas).toBe(false);
});
it('should show builder if all conditions are met', () => {
const builderStore = useBuilderStore();
setAssistantEnabled(true);
expect(builderStore.isAssistantEnabled).toBe(true);
expect(builderStore.canShowAssistant).toBe(true);
expect(builderStore.canShowAssistantButtonsOnCanvas).toBe(true);
});
// Split into two separate tests to avoid caching issues with computed properties
it('should return true when experiment flag is set to variant', () => {
const builderStore = useBuilderStore();
vi.spyOn(posthogStore, 'getVariant').mockReturnValue(WORKFLOW_BUILDER_EXPERIMENT.variant);
expect(builderStore.isAIBuilderEnabled).toBe(true);
});
it('should return false when experiment flag is set to control', () => {
const builderStore = useBuilderStore();
vi.spyOn(posthogStore, 'getVariant').mockReturnValue(WORKFLOW_BUILDER_EXPERIMENT.control);
expect(builderStore.isAIBuilderEnabled).toBe(false);
});
it('should initialize builder chat session with prompt', async () => {
const builderStore = useBuilderStore();
const mockSessionId = 'test-session-id';
apiSpy.mockImplementation((_ctx, _payload, onMessage, onDone) => {
onMessage({
messages: [
{
type: 'message',
role: 'assistant',
text: 'How can I help you build a workflow?',
},
],
sessionId: mockSessionId,
});
onDone();
});
await builderStore.initBuilderChat('I want to build a workflow', 'chat');
expect(apiSpy).toHaveBeenCalled();
expect(builderStore.currentSessionId).toEqual(mockSessionId);
expect(builderStore.chatMessages.length).toBe(2); // user message + assistant response
expect(builderStore.chatMessages[0].role).toBe('user');
expect(builderStore.chatMessages[1].role).toBe('assistant');
});
it('should send a follow-up message in an existing session', async () => {
const builderStore = useBuilderStore();
const mockSessionId = 'test-session-id';
// Setup initial session
apiSpy.mockImplementationOnce((_ctx, _payload, onMessage, onDone) => {
onMessage({
messages: [
{
type: 'message',
role: 'assistant',
text: 'How can I help you build a workflow?',
},
],
sessionId: mockSessionId,
});
onDone();
});
// Setup follow-up message response
apiSpy.mockImplementationOnce((_ctx, _payload, onMessage, onDone) => {
onMessage({
messages: [
{
type: 'message',
role: 'assistant',
text: 'Here are some workflow ideas',
},
],
sessionId: mockSessionId,
});
onDone();
});
await builderStore.initBuilderChat('I want to build a workflow', 'chat');
// Should be 2 messages now (user question + assistant response)
expect(builderStore.chatMessages.length).toBe(2);
// Send a follow-up message
await builderStore.sendMessage({ text: 'Generate a workflow for me' });
const thirdMessage = builderStore.chatMessages[2] as ChatUI.TextMessage;
const fourthMessage = builderStore.chatMessages[3] as ChatUI.TextMessage;
// Should be 4 messages now (2 initial + user follow-up + assistant response)
expect(builderStore.chatMessages.length).toBe(4);
expect(thirdMessage.role).toBe('user');
expect(thirdMessage.type).toBe('text');
expect(thirdMessage.content).toBe('Generate a workflow for me');
expect(fourthMessage.role).toBe('assistant');
expect(fourthMessage.type).toBe('text');
expect(fourthMessage.content).toBe('Here are some workflow ideas');
});
it('should properly handle errors in chat session', async () => {
const builderStore = useBuilderStore();
// Simulate an error response
apiSpy.mockImplementationOnce((_ctx, _payload, _onMessage, _onDone, onError) => {
onError(new Error('An API error occurred'));
});
await builderStore.initBuilderChat('I want to build a workflow', 'chat');
// Should have user message + error message
expect(builderStore.chatMessages.length).toBe(2);
expect(builderStore.chatMessages[0].role).toBe('user');
expect(builderStore.chatMessages[1].type).toBe('error');
// Error message should have a retry function
const errorMessage = builderStore.chatMessages[1] as ChatUI.ErrorMessage;
expect(errorMessage.retry).toBeDefined();
// Set up a successful response for the retry
apiSpy.mockImplementationOnce((_ctx, _payload, onMessage, onDone) => {
onMessage({
messages: [
{
type: 'message',
role: 'assistant',
text: 'I can help you build a workflow',
},
],
sessionId: 'new-session',
});
onDone();
});
// Retry the failed request
await errorMessage.retry?.();
// Should now have just the user message and success message
expect(builderStore.chatMessages.length).toBe(2);
expect(builderStore.chatMessages[0].role).toBe('user');
expect(builderStore.chatMessages[1].type).toBe('text');
expect((builderStore.chatMessages[1] as ChatUI.TextMessage).content).toBe(
'I can help you build a workflow',
);
});
});

View File

@@ -0,0 +1,365 @@
import { chatWithBuilder } from '@/api/ai';
import type { VIEWS } from '@/constants';
import { EDITABLE_CANVAS_VIEWS, STORES, WORKFLOW_BUILDER_EXPERIMENT } from '@/constants';
import type { ChatRequest } from '@/types/assistant.types';
import type { ChatUI } from '@n8n/design-system/types/assistant';
import { defineStore } from 'pinia';
import { computed, ref, watch } from 'vue';
import { useRootStore } from './root.store';
import { useUsersStore } from './users.store';
import { useRoute } from 'vue-router';
import { useSettingsStore } from './settings.store';
import { assert } from '@n8n/utils/assert';
import { useI18n } from '@/composables/useI18n';
import { useTelemetry } from '@/composables/useTelemetry';
import { useUIStore } from './ui.store';
import { usePostHog } from './posthog.store';
import { useNodeTypesStore } from './nodeTypes.store';
import { DEFAULT_CHAT_WIDTH, MAX_CHAT_WIDTH, MIN_CHAT_WIDTH } from './assistant.store';
export const ENABLED_VIEWS = [...EDITABLE_CANVAS_VIEWS];
export const useBuilderStore = defineStore(STORES.BUILDER, () => {
// Core state
const chatWidth = ref<number>(DEFAULT_CHAT_WIDTH);
const chatMessages = ref<ChatUI.AssistantMessage[]>([]);
const chatWindowOpen = ref<boolean>(false);
const streaming = ref<boolean>(false);
const currentSessionId = ref<string | undefined>();
const assistantThinkingMessage = ref<string | undefined>();
// Store dependencies
const settings = useSettingsStore();
const rootStore = useRootStore();
const usersStore = useUsersStore();
const uiStore = useUIStore();
const route = useRoute();
const locale = useI18n();
const telemetry = useTelemetry();
const posthogStore = usePostHog();
const nodeTypesStore = useNodeTypesStore();
// Computed properties
const isAssistantEnabled = computed(() => settings.isAiAssistantEnabled);
const workflowPrompt = computed(() => {
const firstUserMessage = chatMessages.value.find(
(msg) => msg.role === 'user' && msg.type === 'text',
) as ChatUI.TextMessage;
return firstUserMessage?.content;
});
const canShowAssistant = computed(
() => isAssistantEnabled.value && ENABLED_VIEWS.includes(route.name as VIEWS),
);
const canShowAssistantButtonsOnCanvas = computed(
() => isAssistantEnabled.value && EDITABLE_CANVAS_VIEWS.includes(route.name as VIEWS),
);
const isAssistantOpen = computed(() => canShowAssistant.value && chatWindowOpen.value);
const isAIBuilderEnabled = computed(() => {
return (
posthogStore.getVariant(WORKFLOW_BUILDER_EXPERIMENT.name) ===
WORKFLOW_BUILDER_EXPERIMENT.variant
);
});
// No need to track unread messages in the AI Builder
const unreadCount = computed(() => 0);
// Chat management functions
function resetBuilderChat() {
clearMessages();
currentSessionId.value = undefined;
assistantThinkingMessage.value = undefined;
}
function openChat() {
chatWindowOpen.value = true;
chatMessages.value = chatMessages.value.map((msg) => ({ ...msg, read: true }));
uiStore.appGridDimensions = {
...uiStore.appGridDimensions,
width: window.innerWidth - chatWidth.value,
};
}
function closeChat() {
chatWindowOpen.value = false;
// Looks smoother if we wait for slide animation to finish before updating the grid width
setTimeout(() => {
uiStore.appGridDimensions = {
...uiStore.appGridDimensions,
width: window.innerWidth,
};
}, 200);
}
function clearMessages() {
chatMessages.value = [];
}
function updateWindowWidth(width: number) {
chatWidth.value = Math.min(Math.max(width, MIN_CHAT_WIDTH), MAX_CHAT_WIDTH);
}
// Message handling functions
function addAssistantMessages(newMessages: ChatRequest.MessageResponse[], id: string) {
const read = true; // Always mark as read in builder
const messages = [...chatMessages.value].filter(
(msg) => !(msg.id === id && msg.role === 'assistant'),
);
assistantThinkingMessage.value = undefined;
newMessages.forEach((msg) => {
if (msg.type === 'message') {
messages.push({
id,
type: 'text',
role: 'assistant',
content: msg.text,
quickReplies: msg.quickReplies,
codeSnippet: msg.codeSnippet,
read,
});
} else if (msg.type === 'workflow-step' && 'steps' in msg) {
messages.push({
id,
type: 'workflow-step',
role: 'assistant',
steps: msg.steps,
read,
});
} else if (msg.type === 'prompt-validation' && !msg.isWorkflowPrompt) {
messages.push({
id,
role: 'assistant',
type: 'error',
content: locale.baseText('aiAssistant.builder.invalidPrompt'),
read: true,
});
} else if (msg.type === 'workflow-node' && 'nodes' in msg) {
const mappedNodes = msg.nodes.map(
(node) => nodeTypesStore.getNodeType(node)?.displayName ?? node,
);
messages.push({
id,
type: 'workflow-node',
role: 'assistant',
nodes: mappedNodes,
read,
});
} else if (msg.type === 'workflow-composed' && 'nodes' in msg) {
messages.push({
id,
type: 'workflow-composed',
role: 'assistant',
nodes: msg.nodes,
read,
});
} else if (msg.type === 'workflow-generated' && 'codeSnippet' in msg) {
messages.push({
id,
type: 'workflow-generated',
role: 'assistant',
codeSnippet: msg.codeSnippet,
read,
});
} else if (msg.type === 'rate-workflow') {
messages.push({
id,
type: 'rate-workflow',
role: 'assistant',
content: msg.content,
read,
});
}
});
chatMessages.value = messages;
}
function addAssistantError(content: string, id: string, retry?: () => Promise<void>) {
chatMessages.value.push({
id,
role: 'assistant',
type: 'error',
content,
read: true,
retry,
});
}
function addLoadingAssistantMessage(message: string) {
assistantThinkingMessage.value = message;
}
function addUserMessage(content: string, id: string) {
chatMessages.value.push({
id,
role: 'user',
type: 'text',
content,
read: true,
});
}
function stopStreaming() {
streaming.value = false;
}
// Error handling
function handleServiceError(e: unknown, id: string, retry?: () => Promise<void>) {
assert(e instanceof Error);
stopStreaming();
assistantThinkingMessage.value = undefined;
addAssistantError(
locale.baseText('aiAssistant.serviceError.message', { interpolate: { message: e.message } }),
id,
retry,
);
telemetry.track('Workflow generation errored', {
error: e.message,
prompt: workflowPrompt.value,
});
}
// API interaction
function getRandomId() {
return `${Math.floor(Math.random() * 100000000)}`;
}
function onEachStreamingMessage(response: ChatRequest.ResponsePayload, id: string) {
if (response.sessionId && !currentSessionId.value) {
currentSessionId.value = response.sessionId;
telemetry.track(
'Assistant session started',
{
chat_session_id: currentSessionId.value,
task: 'workflow-generation',
},
{ withPostHog: true },
);
} else if (currentSessionId.value !== response.sessionId) {
// Ignore messages from other sessions
return;
}
addAssistantMessages(response.messages, id);
}
function onDoneStreaming() {
stopStreaming();
}
// Core API functions
async function initBuilderChat(userMessage: string, source: 'chat' | 'canvas') {
telemetry.track(
'User submitted workflow prompt',
{
source,
prompt: userMessage,
},
{ withPostHog: true },
);
resetBuilderChat();
const id = getRandomId();
addUserMessage(userMessage, id);
addLoadingAssistantMessage(locale.baseText('aiAssistant.thinkingSteps.thinking'));
openChat();
streaming.value = true;
const payload: ChatRequest.InitBuilderChat = {
role: 'user',
type: 'init-builder-chat',
user: {
firstName: usersStore.currentUser?.firstName ?? '',
},
question: userMessage,
};
chatWithBuilder(
rootStore.restApiContext,
{
payload,
},
(msg) => onEachStreamingMessage(msg, id),
() => onDoneStreaming(),
(e) => handleServiceError(e, id, async () => await initBuilderChat(userMessage, 'chat')),
);
}
async function sendMessage(
chatMessage: Pick<ChatRequest.UserChatMessage, 'text' | 'quickReplyType'>,
) {
if (streaming.value) {
return;
}
const id = getRandomId();
const retry = async () => {
chatMessages.value = chatMessages.value.filter((msg) => msg.id !== id);
await sendMessage(chatMessage);
};
try {
addUserMessage(chatMessage.text, id);
addLoadingAssistantMessage(locale.baseText('aiAssistant.thinkingSteps.thinking'));
streaming.value = true;
assert(currentSessionId.value);
chatWithBuilder(
rootStore.restApiContext,
{
payload: {
role: 'user',
type: 'message',
text: chatMessage.text,
quickReplyType: chatMessage.quickReplyType,
},
sessionId: currentSessionId.value,
},
(msg) => onEachStreamingMessage(msg, id),
() => onDoneStreaming(),
(e) => handleServiceError(e, id, retry),
);
} catch (e: unknown) {
// in case of assert
handleServiceError(e, id, retry);
}
}
// Reset on route change
watch(route, () => {
resetBuilderChat();
});
// Public API
return {
// State
isAssistantEnabled,
canShowAssistantButtonsOnCanvas,
chatWidth,
chatMessages,
unreadCount,
streaming,
isAssistantOpen,
canShowAssistant,
currentSessionId,
assistantThinkingMessage,
chatWindowOpen,
isAIBuilderEnabled,
workflowPrompt,
// Methods
updateWindowWidth,
closeChat,
openChat,
resetBuilderChat,
initBuilderChat,
sendMessage,
addAssistantMessages,
handleServiceError,
};
});

View File

@@ -63,6 +63,17 @@ export namespace ChatRequest {
question: string;
}
export interface InitBuilderChat {
role: 'user';
type: 'init-builder-chat';
user: {
firstName: string;
};
context?: UserContext & WorkflowContext;
workflowContext?: WorkflowContext;
question: string;
}
export interface InitCredHelp {
role: 'user';
type: 'init-cred-help';
@@ -116,7 +127,7 @@ export namespace ChatRequest {
export type RequestPayload =
| {
payload: InitErrorHelper | InitSupportChat | InitCredHelp;
payload: InitErrorHelper | InitSupportChat | InitCredHelp | InitBuilderChat;
}
| {
payload: EventRequestPayload | UserChatMessage;
@@ -173,6 +184,44 @@ export namespace ChatRequest {
step: string;
}
interface WorkflowStepMessage {
role: 'assistant';
type: 'workflow-step';
steps: string[];
}
interface WorkflowNodeMessage {
role: 'assistant';
type: 'workflow-node';
nodes: string[];
}
interface WorkflowPromptValidationMessage {
role: 'assistant';
type: 'prompt-validation';
isWorkflowPrompt: boolean;
}
interface WorkflowComposedMessage {
role: 'assistant';
type: 'workflow-composed';
nodes: Array<{
parameters: Record<string, unknown>;
type: string;
name: string;
position: [number, number];
}>;
}
interface WorkflowGeneratedMessage {
role: 'assistant';
type: 'workflow-generated';
codeSnippet: string;
}
interface RateWorkflowMessage {
role: 'assistant';
type: 'rate-workflow';
content: string;
}
export type MessageResponse =
| ((
| AssistantChatMessage
@@ -180,6 +229,12 @@ export namespace ChatRequest {
| AssistantSummaryMessage
| AgentChatMessage
| AgentThinkingStep
| WorkflowStepMessage
| WorkflowNodeMessage
| WorkflowComposedMessage
| WorkflowPromptValidationMessage
| WorkflowGeneratedMessage
| RateWorkflowMessage
) & {
quickReplies?: QuickReplyOption[];
})

View File

@@ -11,6 +11,7 @@ import type {
import type { IExecutionResponse, INodeUi } from '@/Interface';
import type { ComputedRef, Ref } from 'vue';
import type { EventBus } from '@n8n/utils/event-bus';
import type { CanvasLayoutSource } from '@/composables/useCanvasLayout';
import type { NodeIconSource } from '../utils/nodeIcon';
export const enum CanvasConnectionMode {
@@ -44,6 +45,7 @@ export const enum CanvasNodeRenderType {
Default = 'default',
StickyNote = 'n8n-nodes-base.stickyNote',
AddNodes = 'n8n-nodes-internal.addNodes',
AIPrompt = 'n8n-nodes-base.aiPrompt',
}
export type CanvasNodeDefaultRenderLabelSize = 'small' | 'medium' | 'large';
@@ -81,6 +83,11 @@ export type CanvasNodeAddNodesRender = {
options: Record<string, never>;
};
export type CanvasNodeAIPromptRender = {
type: CanvasNodeRenderType.AIPrompt;
options: Record<string, never>;
};
export type CanvasNodeStickyNoteRender = {
type: CanvasNodeRenderType.StickyNote;
options: Partial<{
@@ -122,7 +129,11 @@ export interface CanvasNodeData {
iterations: number;
visible: boolean;
};
render: CanvasNodeDefaultRender | CanvasNodeStickyNoteRender | CanvasNodeAddNodesRender;
render:
| CanvasNodeDefaultRender
| CanvasNodeStickyNoteRender
| CanvasNodeAddNodesRender
| CanvasNodeAIPromptRender;
}
export type CanvasNode = Node<CanvasNodeData>;
@@ -170,6 +181,7 @@ export type CanvasEventBusEvents = {
action: keyof CanvasNodeEventBusEvents;
payload?: CanvasNodeEventBusEvents[keyof CanvasNodeEventBusEvents];
};
tidyUp: { source: CanvasLayoutSource };
};
export interface CanvasNodeInjectionData {

View File

@@ -114,6 +114,7 @@ import { getEasyAiWorkflowJson } from '@/utils/easyAiWorkflowUtils';
import type { CanvasLayoutEvent } from '@/composables/useCanvasLayout';
import { useClearExecutionButtonVisible } from '@/composables/useClearExecutionButtonVisible';
import { LOGS_PANEL_STATE } from '@/components/CanvasChat/types/logs';
import { useBuilderStore } from '@/stores/builder.store';
import { useFoldersStore } from '@/stores/folders.store';
defineOptions({
@@ -165,6 +166,7 @@ const tagsStore = useTagsStore();
const pushConnectionStore = usePushConnectionStore();
const ndvStore = useNDVStore();
const templatesStore = useTemplatesStore();
const builderStore = useBuilderStore();
const foldersStore = useFoldersStore();
const canvasEventBus = createEventBus<CanvasEventBusEvents>();
@@ -228,6 +230,7 @@ const isExecutionPreview = ref(false);
const canOpenNDV = ref(true);
const hideNodeIssues = ref(false);
const fallbackNodes = ref<INodeUi[]>([]);
const initializedWorkflowId = ref<string | undefined>();
const workflowId = computed(() => {
@@ -254,21 +257,6 @@ const isCanvasReadOnly = computed(() => {
);
});
const fallbackNodes = computed<INodeUi[]>(() =>
isLoading.value || isCanvasReadOnly.value
? []
: [
{
id: CanvasNodeRenderType.AddNodes,
name: CanvasNodeRenderType.AddNodes,
type: CanvasNodeRenderType.AddNodes,
typeVersion: 1,
position: [0, 0],
parameters: {},
},
],
);
const showFallbackNodes = computed(() => triggerNodes.value.length === 0);
const keyBindingsEnabled = computed(() => {
@@ -632,7 +620,12 @@ function onRevertNodePosition({ nodeName, position }: { nodeName: string; positi
}
function onDeleteNode(id: string) {
deleteNode(id, { trackHistory: true });
const matchedFallbackNode = fallbackNodes.value.findIndex((node) => node.id === id);
if (matchedFallbackNode >= 0) {
fallbackNodes.value.splice(matchedFallbackNode, 1);
} else {
deleteNode(id, { trackHistory: true });
}
}
function onDeleteNodes(ids: string[]) {
@@ -972,6 +965,11 @@ async function onImportWorkflowDataEvent(data: IDataObject) {
fitView();
selectNodes(workflowData.nodes?.map((node) => node.id) ?? []);
if (data.tidyUp) {
setTimeout(() => {
canvasEventBus.emit('tidyUp', { source: 'import-workflow-data' });
}, 0);
}
}
async function onImportWorkflowUrlEvent(data: IDataObject) {
@@ -1673,6 +1671,37 @@ watch(
},
);
watch(
() => {
return isLoading.value || isCanvasReadOnly.value || editableWorkflow.value.nodes.length !== 0;
},
(isReadOnlyOrLoading) => {
const defaultFallbackNodes: INodeUi[] = [
{
id: CanvasNodeRenderType.AddNodes,
name: CanvasNodeRenderType.AddNodes,
type: CanvasNodeRenderType.AddNodes,
typeVersion: 1,
position: [0, 0],
parameters: {},
},
];
if (builderStore.isAIBuilderEnabled && builderStore.isAssistantEnabled) {
defaultFallbackNodes.unshift({
id: CanvasNodeRenderType.AIPrompt,
name: CanvasNodeRenderType.AIPrompt,
type: CanvasNodeRenderType.AIPrompt,
typeVersion: 1,
position: [-690, -15],
parameters: {},
});
}
fallbackNodes.value = isReadOnlyOrLoading ? [] : defaultFallbackNodes;
},
);
// This keeps the selected node in sync if the URL is updated
watch(
() => route.params.nodeId,

17
pnpm-lock.yaml generated
View File

@@ -18,6 +18,9 @@ catalogs:
'@langchain/openai':
specifier: 0.3.17
version: 0.3.17
'@n8n_io/ai-assistant-sdk':
specifier: 1.14.0
version: 1.14.0
'@sentry/node':
specifier: 8.52.1
version: 8.52.1
@@ -345,8 +348,8 @@ importers:
specifier: workspace:*
version: link:../di
'@n8n_io/ai-assistant-sdk':
specifier: 1.13.0
version: 1.13.0
specifier: 'catalog:'
version: 1.14.0
n8n-workflow:
specifier: workspace:*
version: link:../../workflow
@@ -1044,8 +1047,8 @@ importers:
specifier: 0.3.20-12
version: 0.3.20-12(@sentry/node@8.52.1)(ioredis@5.3.2)(mssql@10.0.2)(mysql2@3.11.0)(pg@8.12.0)(redis@4.6.14)(sqlite3@5.1.7)(ts-node@10.9.2(@types/node@18.16.16)(typescript@5.8.2))
'@n8n_io/ai-assistant-sdk':
specifier: 1.13.0
version: 1.13.0
specifier: 'catalog:'
version: 1.14.0
'@n8n_io/license-sdk':
specifier: 2.20.0
version: 2.20.0
@@ -4856,8 +4859,8 @@ packages:
engines: {node: '>=18.10', pnpm: '>=9.6'}
hasBin: true
'@n8n_io/ai-assistant-sdk@1.13.0':
resolution: {integrity: sha512-16kftFTeX3/lBinHJaBK0OL1lB4FpPaUoHX4h25AkvgHvmjUHpWNY2ZtKos0rY89+pkzDsNxMZqSUkeKU45iRg==}
'@n8n_io/ai-assistant-sdk@1.14.0':
resolution: {integrity: sha512-apo1VXGmyUpwsBZ2dp9EyqZYR+FA3DzdD79MVzSMuknLIukhyZjvabBYRrjK5BJOU3vo5z9inaCm4UvenQ5Mgg==}
engines: {node: '>=20.15', pnpm: '>=8.14'}
'@n8n_io/license-sdk@2.20.0':
@@ -17313,7 +17316,7 @@ snapshots:
acorn: 8.12.1
acorn-walk: 8.3.4
'@n8n_io/ai-assistant-sdk@1.13.0': {}
'@n8n_io/ai-assistant-sdk@1.14.0': {}
'@n8n_io/license-sdk@2.20.0':
dependencies:

View File

@@ -39,6 +39,7 @@ catalog:
'@langchain/openai': 0.3.17
'@langchain/anthropic': 0.3.11
'@langchain/community': 0.3.24
'@n8n_io/ai-assistant-sdk': 1.14.0
catalogs:
frontend: