feat: AI Workflow Builder agent (no-changelog) (#17423)

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
This commit is contained in:
oleg
2025-07-21 11:18:26 +02:00
committed by GitHub
parent c0f1867429
commit 632b38119b
133 changed files with 18499 additions and 2867 deletions

View File

@@ -399,73 +399,147 @@ RichTextMessage.args = {
]),
};
export const WorkflowStepsChat = Template.bind({});
WorkflowStepsChat.args = {
export const TextMessageWithRegularRating = Template.bind({});
TextMessageWithRegularRating.args = {
user: {
firstName: 'Max',
lastName: 'Test',
},
messages: getMessages([
{
id: '123',
type: 'workflow-step',
id: '127',
type: 'text',
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',
],
content:
"I've generated a workflow that automatically processes your CSV files and sends email notifications. The workflow includes error handling and data validation steps.",
read: false,
showRating: true,
ratingStyle: 'regular',
showFeedback: true,
},
]),
};
export const WorkflowNodesChat = Template.bind({});
WorkflowNodesChat.args = {
export const TextMessageWithMinimalRating = Template.bind({});
TextMessageWithMinimalRating.args = {
user: {
firstName: 'Max',
lastName: 'Test',
},
messages: getMessages([
{
id: '124',
type: 'workflow-node',
id: '128',
type: 'text',
role: 'assistant',
nodes: ['HTTP Trigger', 'Transform', 'PostgreSQL', 'SendGrid'],
content:
"Here's a quick tip: You can use the Code node to transform data between different formats.",
read: false,
showRating: true,
ratingStyle: 'minimal',
showFeedback: true,
},
]),
};
export const ComposedNodesChat = Template.bind({});
ComposedNodesChat.args = {
export const MultipleMessagesWithRatings = Template.bind({});
MultipleMessagesWithRatings.args = {
user: {
firstName: 'Max',
lastName: 'Test',
},
messages: getMessages([
{
id: '125',
type: 'workflow-composed',
id: '129',
type: 'text',
role: 'user',
content: 'Can you help me create a workflow for processing webhooks?',
read: true,
},
{
id: '130',
type: 'text',
role: 'assistant',
nodes: [
content: "I'll help you create a webhook processing workflow. Here are the steps:",
read: true,
showRating: true,
ratingStyle: 'minimal',
showFeedback: true,
},
{
id: '131',
type: 'text',
role: 'assistant',
content: `Follow these steps:
1. Add a Webhook node to receive incoming data
2. Use a Switch node to route based on webhook type
3. Add data transformation with a Code node
4. Store results in your database`,
read: true,
},
{
id: '132',
type: 'text',
role: 'assistant',
content:
'This workflow will handle incoming webhooks efficiently and store the processed data.',
read: false,
showRating: true,
ratingStyle: 'regular',
showFeedback: true,
},
]),
};
export const CodeDiffWithMinimalRating = Template.bind({});
CodeDiffWithMinimalRating.args = {
user: {
firstName: 'Max',
lastName: 'Test',
},
messages: getMessages([
{
id: '133',
type: 'code-diff',
role: 'assistant',
description: 'Fix the error handling in your code',
codeDiff:
'@@ -1,3 +1,8 @@\\n const data = await fetchData();\\n-return data;\\n+\\n+if (!data || data.error) {\\n+ throw new Error(data?.error || "Failed to fetch data");\\n+}\\n+\\n+return data;',
suggestionId: 'fix_error_handling',
read: false,
showRating: true,
ratingStyle: 'minimal',
showFeedback: true,
},
]),
};
export const ToolMessageRunning = Template.bind({});
ToolMessageRunning.args = {
user: {
firstName: 'Max',
lastName: 'Test',
},
messages: getMessages([
{
id: '127',
type: 'tool',
role: 'assistant',
toolName: 'code_tool',
toolCallId: 'call_123',
status: 'running',
updates: [
{
name: 'HTTP Trigger',
type: 'n8n-nodes-base.httpTrigger',
parameters: {
path: '/webhook',
authentication: 'none',
},
position: [100, 100],
type: 'progress',
data: { message: 'Analyzing the codebase structure...' },
timestamp: new Date().toISOString(),
},
{
name: 'Transform',
type: 'n8n-nodes-base.set',
parameters: {
values: { field: 'value' },
type: 'input',
data: {
query: 'Find all Vue components in the project',
path: '/src/components',
},
position: [300, 100],
timestamp: new Date().toISOString(),
},
],
read: false,
@@ -473,18 +547,169 @@ ComposedNodesChat.args = {
]),
};
export const RateWorkflowMessage = Template.bind({});
RateWorkflowMessage.args = {
export const ToolMessageCompleted = Template.bind({});
ToolMessageCompleted.args = {
user: {
firstName: 'Max',
lastName: 'Test',
},
messages: getMessages([
{
id: '126',
type: 'rate-workflow',
id: '128',
type: 'tool',
role: 'assistant',
content: 'Is this workflow helpful?',
toolName: 'search_files',
toolCallId: 'call_456',
status: 'completed',
updates: [
{
type: 'input',
data: {
pattern: '*.vue',
directory: '/src',
},
timestamp: new Date().toISOString(),
},
{
type: 'progress',
data: { message: 'Searching for Vue files...' },
timestamp: new Date().toISOString(),
},
{
type: 'output',
data: {
files: [
'/src/components/Button.vue',
'/src/components/Modal.vue',
'/src/views/Home.vue',
],
count: 3,
},
timestamp: new Date().toISOString(),
},
],
read: false,
},
]),
};
export const ToolMessageError = Template.bind({});
ToolMessageError.args = {
user: {
firstName: 'Max',
lastName: 'Test',
},
messages: getMessages([
{
id: '129',
type: 'tool',
role: 'assistant',
toolName: 'database_query',
toolCallId: 'call_789',
status: 'error',
updates: [
{
type: 'input',
data: {
query: 'SELECT * FROM users WHERE id = 123',
database: 'production',
},
timestamp: new Date().toISOString(),
},
{
type: 'progress',
data: { message: 'Connecting to database...' },
timestamp: new Date().toISOString(),
},
{
type: 'error',
data: {
error: 'Connection timeout',
details: 'Failed to connect to database after 30 seconds',
},
timestamp: new Date().toISOString(),
},
],
read: false,
},
]),
};
export const MixedMessagesWithTools = Template.bind({});
MixedMessagesWithTools.args = {
user: {
firstName: 'Max',
lastName: 'Test',
},
messages: getMessages([
{
id: '130',
type: 'text',
role: 'user',
content: 'Can you help me analyze my workflow?',
read: true,
},
{
id: '131',
type: 'text',
role: 'assistant',
content: "I'll analyze your workflow now. Let me search for the relevant files.",
read: true,
},
{
id: '132',
type: 'tool',
role: 'assistant',
toolName: 'search_workflow_files',
toolCallId: 'call_999',
status: 'completed',
updates: [
{
type: 'input',
data: {
workflowId: 'wf_123',
includeNodes: true,
},
timestamp: new Date().toISOString(),
},
{
type: 'progress',
data: { message: 'Loading workflow configuration...' },
timestamp: new Date().toISOString(),
},
{
type: 'progress',
data: { message: 'Analyzing node connections...' },
timestamp: new Date().toISOString(),
},
{
type: 'output',
data: {
nodes: 5,
connections: 8,
issues: ['Missing error handling in HTTP node', 'Unused variable in Code node'],
},
timestamp: new Date().toISOString(),
},
],
read: true,
},
{
id: '133',
type: 'text',
role: 'assistant',
content: 'I found some issues in your workflow. Here are my recommendations:',
read: true,
},
{
id: '134',
type: 'code-diff',
role: 'assistant',
description: 'Add error handling to your HTTP node',
codeDiff:
// eslint-disable-next-line n8n-local-rules/no-interpolation-in-regular-string
'@@ -1,3 +1,8 @@\n const response = await $http.request(options);\n-return response.data;\n+\n+if (response.status !== 200) {\n+ throw new Error(`HTTP request failed with status ${response.status}`);\n+}\n+\n+return response.data;',
suggestionId: 'fix_http_error',
read: false,
},
]),

View File

@@ -1,18 +1,9 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { computed, nextTick, ref, watch } 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 MessageWrapper from './messages/MessageWrapper.vue';
import { useI18n } from '../../composables/useI18n';
import type { ChatUI } from '../../types/assistant';
import type { ChatUI, RatingFeedback } from '../../types/assistant';
import AssistantIcon from '../AskAssistantIcon/AssistantIcon.vue';
import AssistantLoadingMessage from '../AskAssistantLoadingMessage/AssistantLoadingMessage.vue';
import AssistantText from '../AskAssistantText/AssistantText.vue';
@@ -36,6 +27,7 @@ interface Props {
sessionId?: string;
title?: string;
placeholder?: string;
scrollOnNewMessage?: boolean;
}
const emit = defineEmits<{
@@ -43,9 +35,7 @@ const emit = defineEmits<{
message: [string, string?, boolean?];
codeReplace: [number];
codeUndo: [number];
thumbsUp: [];
thumbsDown: [];
submitFeedback: [string];
feedback: [RatingFeedback];
}>();
const onClose = () => emit('close');
@@ -59,11 +49,22 @@ const props = withDefaults(defineProps<Props>(), {
messages: () => [],
loadingMessage: undefined,
sessionId: undefined,
scrollOnNewMessage: false,
});
// Ensure all messages have required id and read properties
const normalizedMessages = computed(() => {
return props.messages.map((msg, index) => ({
...msg,
id: msg.id || `msg-${index}`,
read: msg.read ?? true,
}));
});
const textInputValue = ref<string>('');
const chatInput = ref<HTMLTextAreaElement | null>(null);
const messagesRef = ref<HTMLDivElement | null>(null);
const sessionEnded = computed(() => {
return isEndOfSessionEvent(props.messages?.[props.messages.length - 1]);
@@ -101,17 +102,36 @@ function growInput() {
chatInput.value.style.height = `${Math.min(scrollHeight, MAX_CHAT_INPUT_HEIGHT)}px`;
}
function onThumbsUp() {
emit('thumbsUp');
function onRateMessage(feedback: RatingFeedback) {
emit('feedback', feedback);
}
function onThumbsDown() {
emit('thumbsDown');
}
function onSubmitFeedback(feedback: string) {
emit('submitFeedback', feedback);
function scrollToBottom() {
if (messagesRef.value) {
messagesRef.value?.scrollTo({
top: messagesRef.value.scrollHeight,
behavior: 'smooth',
});
}
}
watch(sendDisabled, () => {
chatInput.value?.focus();
});
watch(
() => props.messages,
async (messages) => {
// Check if the last message is user and scroll to bottom of the chat
if (props.scrollOnNewMessage && messages.length > 0) {
// Wait for DOM updates before scrolling
await nextTick();
// Check if messagesRef is available after nextTick
if (messagesRef.value) {
scrollToBottom();
}
}
},
{ immediate: true, deep: true },
);
</script>
<template>
@@ -129,85 +149,28 @@ function onSubmitFeedback(feedback: string) {
</div>
</div>
<div :class="$style.body">
<div v-if="messages?.length || loadingMessage" :class="$style.messages">
<div v-if="messages?.length">
<div
v-if="normalizedMessages?.length || loadingMessage"
ref="messagesRef"
:class="$style.messages"
>
<div v-if="normalizedMessages?.length">
<data
v-for="(message, i) in messages"
:key="i"
v-for="(message, i) in normalizedMessages"
:key="message.id"
:data-test-id="
message.role === 'assistant' ? 'chat-message-assistant' : 'chat-message-user'
"
>
<TextMessage
v-if="message.type === 'text'"
<MessageWrapper
:message="message"
:is-first-of-role="i === 0 || message.role !== messages[i - 1].role"
:is-first-of-role="i === 0 || message.role !== normalizedMessages[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'"
: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"
:is-last-message="i === normalizedMessages.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"
@feedback="onRateMessage"
/>
<div
@@ -215,7 +178,7 @@ function onSubmitFeedback(feedback: string) {
!streaming &&
'quickReplies' in message &&
message.quickReplies?.length &&
i === messages.length - 1
i === normalizedMessages.length - 1
"
:class="$style.quickReplies"
>
@@ -237,7 +200,7 @@ function onSubmitFeedback(feedback: string) {
</div>
<div
v-if="loadingMessage"
:class="{ [$style.message]: true, [$style.loading]: messages?.length }"
:class="{ [$style.message]: true, [$style.loading]: normalizedMessages?.length }"
>
<AssistantLoadingMessage :message="loadingMessage" />
</div>
@@ -355,6 +318,20 @@ function onSubmitFeedback(feedback: string) {
padding: var(--spacing-xs);
overflow-y: auto;
@supports not (selector(::-webkit-scrollbar)) {
scrollbar-width: thin;
}
@supports selector(::-webkit-scrollbar) {
&::-webkit-scrollbar {
width: var(--spacing-2xs);
}
&::-webkit-scrollbar-thumb {
border-radius: var(--spacing-xs);
background: var(--color-foreground-dark);
border: var(--spacing-5xs) solid white;
}
}
& + & {
padding-top: 0;
}

View File

@@ -150,6 +150,7 @@ exports[`AskAssistantChat > does not render retry button if no error is present
<!--v-if-->
</div>
<!--v-if-->
</div>
<!--v-if-->
</data>
@@ -338,6 +339,7 @@ exports[`AskAssistantChat > renders chat with messages correctly 1`] = `
<!--v-if-->
</div>
<!--v-if-->
</div>
<!--v-if-->
</data>
@@ -614,6 +616,7 @@ exports[`AskAssistantChat > renders chat with messages correctly 1`] = `
</div>
</div>
<!--v-if-->
</div>
<!--v-if-->
</data>
@@ -658,6 +661,7 @@ exports[`AskAssistantChat > renders chat with messages correctly 1`] = `
<!--v-if-->
</div>
<!--v-if-->
</div>
<!--v-if-->
</data>
@@ -789,6 +793,7 @@ Testing more code
</div>
</div>
<!--v-if-->
</div>
<!--v-if-->
</data>
@@ -918,6 +923,7 @@ Testing more code
</div>
</div>
<!--v-if-->
</div>
<div
class="quickReplies"
@@ -1331,6 +1337,7 @@ exports[`AskAssistantChat > renders end of session chat correctly 1`] = `
<!--v-if-->
</div>
<!--v-if-->
</div>
<!--v-if-->
</data>
@@ -1339,6 +1346,8 @@ exports[`AskAssistantChat > renders end of session chat correctly 1`] = `
>
<div
class="message"
is-last-message="true"
streaming="false"
>
<!--v-if-->
@@ -1407,6 +1416,7 @@ exports[`AskAssistantChat > renders end of session chat correctly 1`] = `
</span>
</div>
<!--v-if-->
</div>
<!--v-if-->
</data>
@@ -1527,6 +1537,8 @@ exports[`AskAssistantChat > renders error message correctly with retry button 1`
>
<div
class="message"
is-last-message="true"
streaming="false"
>
<div
class="roleName userSection"
@@ -1608,6 +1620,7 @@ exports[`AskAssistantChat > renders error message correctly with retry button 1`
/>
</div>
<!--v-if-->
</div>
<!--v-if-->
</data>
@@ -1866,6 +1879,7 @@ catch(e) {
<!--v-if-->
</div>
<!--v-if-->
</div>
<!--v-if-->
</data>
@@ -2056,6 +2070,7 @@ exports[`AskAssistantChat > renders streaming chat correctly 1`] = `
/>
</div>
<!--v-if-->
</div>
<!--v-if-->
</data>

View File

@@ -1,8 +1,9 @@
<script setup lang="ts">
import { computed } from 'vue';
import MessageRating from './MessageRating.vue';
import { useI18n } from '../../../composables/useI18n';
import type { ChatUI } from '../../../types/assistant';
import type { ChatUI, RatingFeedback } from '../../../types/assistant';
import AssistantAvatar from '../../AskAssistantAvatar/AssistantAvatar.vue';
import N8nAvatar from '../../N8nAvatar';
@@ -16,9 +17,18 @@ interface Props {
}
const props = defineProps<Props>();
const emit = defineEmits<{
feedback: [RatingFeedback];
}>();
const { t } = useI18n();
const isUserMessage = computed(() => props.message.role === 'user');
function onRate(rating: RatingFeedback) {
emit('feedback', rating);
}
</script>
<template>
@@ -37,6 +47,12 @@ const isUserMessage = computed(() => props.message.role === 'user');
</template>
</div>
<slot></slot>
<MessageRating
v-if="message.showRating && !isUserMessage"
:style="message.ratingStyle"
:show-feedback="message.showFeedback"
@feedback="onRate"
/>
</div>
</template>

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import BaseMessage from './BaseMessage.vue';
import { useMarkdown } from './useMarkdown';
import type { ChatUI } from '../../../types/assistant';
import type { ChatUI, RatingFeedback } from '../../../types/assistant';
import BlinkingCursor from '../../BlinkingCursor/BlinkingCursor.vue';
interface Props {
@@ -16,11 +16,19 @@ interface Props {
}
defineProps<Props>();
const emit = defineEmits<{
feedback: [RatingFeedback];
}>();
const { renderMarkdown } = useMarkdown();
</script>
<template>
<BaseMessage :message="message" :is-first-of-role="isFirstOfRole" :user="user">
<BaseMessage
:message="message"
:is-first-of-role="isFirstOfRole"
:user="user"
@feedback="(feedback: RatingFeedback) => emit('feedback', feedback)"
>
<div :class="$style.block">
<div :class="$style.blockTitle">
{{ message.title }}

View File

@@ -1,22 +1,10 @@
<script setup lang="ts">
import BaseMessage from './BaseMessage.vue';
import type { ChatUI } from '../../../types/assistant';
import type { ChatUI, RatingFeedback } 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[];
};
message: ChatUI.CodeDiffMessage & { id: string; read: boolean };
isFirstOfRole: boolean;
user?: {
firstName: string;
@@ -31,11 +19,17 @@ defineProps<Props>();
const emit = defineEmits<{
codeReplace: [];
codeUndo: [];
feedback: [RatingFeedback];
}>();
</script>
<template>
<BaseMessage :message="message" :is-first-of-role="isFirstOfRole" :user="user">
<BaseMessage
:message="message"
:is-first-of-role="isFirstOfRole"
:user="user"
@feedback="(feedback: RatingFeedback) => emit('feedback', feedback)"
>
<CodeDiff
:title="message.description"
:content="message.codeDiff"

View File

@@ -0,0 +1,191 @@
<script setup lang="ts">
import { ref } from 'vue';
import { useI18n } from '@n8n/design-system/composables/useI18n';
import type { RatingFeedback } from '@n8n/design-system/types';
import N8nButton from '../../N8nButton';
import N8nIconButton from '../../N8nIconButton';
import N8nInput from '../../N8nInput';
interface Props {
style?: 'regular' | 'minimal';
showFeedback?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
style: 'regular',
showFeedback: true,
});
const emit = defineEmits<{
feedback: [RatingFeedback];
}>();
const { t } = useI18n();
const showRatingButtons = ref(true);
const showFeedbackArea = ref(false);
const showSuccess = ref(false);
const selectedRating = ref<'up' | 'down' | null>(null);
const feedback = ref('');
function onRateButton(rating: 'up' | 'down') {
selectedRating.value = rating;
showRatingButtons.value = false;
emit('feedback', { rating });
if (props.showFeedback) {
showFeedbackArea.value = true;
} else {
showSuccess.value = true;
}
}
function onSubmitFeedback() {
if (selectedRating.value) {
emit('feedback', { feedback: feedback.value });
showFeedbackArea.value = false;
showSuccess.value = true;
}
}
function onCancelFeedback() {
showFeedbackArea.value = false;
showRatingButtons.value = true;
selectedRating.value = null;
feedback.value = '';
}
</script>
<template>
<div :class="[$style.rating, $style[style]]">
<div v-if="showRatingButtons" :class="$style.buttons">
<template v-if="style === 'regular'">
<N8nButton
type="secondary"
size="small"
:label="t('assistantChat.builder.thumbsUp')"
data-test-id="message-thumbs-up-button"
icon="thumbs-up"
@click="onRateButton('up')"
/>
<N8nButton
type="secondary"
size="small"
data-test-id="message-thumbs-down-button"
:label="t('assistantChat.builder.thumbsDown')"
icon="thumbs-down"
@click="onRateButton('down')"
/>
</template>
<template v-else>
<N8nIconButton
type="tertiary"
size="small"
text
icon="thumbs-up"
data-test-id="message-thumbs-up-button"
@click="onRateButton('up')"
/>
<N8nIconButton
type="tertiary"
size="small"
text
icon="thumbs-down"
data-test-id="message-thumbs-down-button"
@click="onRateButton('down')"
/>
</template>
</div>
<div v-if="showFeedbackArea" :class="$style.feedbackContainer">
<N8nInput
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="style === 'minimal' ? 3 : 5"
/>
<div :class="$style.feedbackActions">
<N8nButton
type="secondary"
size="small"
:label="t('generic.cancel')"
@click="onCancelFeedback"
/>
<N8nButton
type="primary"
size="small"
data-test-id="message-submit-feedback-button"
:label="t('assistantChat.builder.submit')"
@click="onSubmitFeedback"
/>
</div>
</div>
<p v-if="showSuccess" :class="$style.success">
{{ t('assistantChat.builder.success') }}
</p>
</div>
</template>
<style lang="scss" module>
.rating {
display: flex;
flex-direction: column;
gap: var(--spacing-2xs);
margin-top: var(--spacing-2xs);
}
.buttons {
display: flex;
gap: var(--spacing-2xs);
}
.feedbackContainer {
display: flex;
flex-direction: column;
gap: var(--spacing-2xs);
}
.feedbackInput {
:global(.el-textarea__inner) {
resize: none;
font-family: var(--font-family);
font-size: var(--font-size-2xs);
}
}
.feedbackActions {
display: flex;
gap: var(--spacing-2xs);
justify-content: flex-end;
}
.success {
color: var(--color-success);
font-size: var(--font-size-2xs);
margin: 0;
}
/* Minimal style specific */
.minimal {
margin-top: 0;
.buttons {
gap: var(--spacing-3xs);
}
.feedbackContainer {
gap: var(--spacing-3xs);
}
.feedbackInput {
:global(.el-textarea__inner) {
font-size: var(--font-size-3xs);
}
}
}
</style>

View File

@@ -0,0 +1,69 @@
<!-- eslint-disable @typescript-eslint/naming-convention -->
<!-- eslint-disable @typescript-eslint/no-unsafe-return Needed to fix types issue with imported components to satisfy Component type -->
<script setup lang="ts">
import { computed, type Component } from 'vue';
import BlockMessage from './BlockMessage.vue';
import CodeDiffMessage from './CodeDiffMessage.vue';
import ErrorMessage from './ErrorMessage.vue';
import EventMessage from './EventMessage.vue';
import TextMessage from './TextMessage.vue';
import ToolMessage from './ToolMessage.vue';
import type { ChatUI, RatingFeedback } from '../../../types/assistant';
interface Props {
message: ChatUI.AssistantMessage;
isFirstOfRole: boolean;
user?: {
firstName: string;
lastName: string;
};
streaming?: boolean;
isLastMessage?: boolean;
}
const props = defineProps<Props>();
const emit = defineEmits<{
codeReplace: [];
codeUndo: [];
feedback: [RatingFeedback];
}>();
const messageComponent = computed<Component | null>(() => {
switch (props.message.type) {
case 'text':
return TextMessage;
case 'block':
return BlockMessage;
case 'code-diff':
return CodeDiffMessage;
case 'error':
return ErrorMessage;
case 'event':
return EventMessage;
case 'tool':
return ToolMessage;
case 'agent-suggestion':
case 'workflow-updated':
return null;
default:
return null;
}
});
</script>
<template>
<component
:is="messageComponent"
v-if="messageComponent"
:message="message"
:is-first-of-role="isFirstOfRole"
:user="user"
:streaming="streaming"
:is-last-message="isLastMessage"
@code-replace="emit('codeReplace')"
@code-undo="emit('codeUndo')"
@feedback="(feedback: RatingFeedback) => emit('feedback', feedback)"
/>
</template>

View File

@@ -4,12 +4,12 @@ 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 type { ChatUI, RatingFeedback } from '../../../types/assistant';
import BlinkingCursor from '../../BlinkingCursor/BlinkingCursor.vue';
import N8nButton from '../../N8nButton';
interface Props {
message: ChatUI.TextMessage & { id: string; read: boolean; quickReplies?: ChatUI.QuickReply[] };
message: ChatUI.TextMessage & { quickReplies?: ChatUI.QuickReply[] };
isFirstOfRole: boolean;
user?: {
firstName: string;
@@ -20,6 +20,10 @@ interface Props {
}
defineProps<Props>();
const emit = defineEmits<{
feedback: [RatingFeedback];
}>();
const { renderMarkdown } = useMarkdown();
const { t } = useI18n();
@@ -38,7 +42,12 @@ async function onCopyButtonClick(content: string, e: MouseEvent) {
</script>
<template>
<BaseMessage :message="message" :is-first-of-role="isFirstOfRole" :user="user">
<BaseMessage
:message="message"
:is-first-of-role="isFirstOfRole"
:user="user"
@feedback="(feedback) => emit('feedback', feedback)"
>
<div :class="$style.textMessage">
<span
v-if="message.role === 'user'"

View File

@@ -0,0 +1,279 @@
<script setup lang="ts">
import { ref, computed } from 'vue';
import { useI18n } from '@n8n/design-system/composables/useI18n';
import BaseMessage from './BaseMessage.vue';
import type { ChatUI } from '../../../types/assistant';
import N8nIcon from '../../N8nIcon';
import N8nTooltip from '../../N8nTooltip';
interface Props {
message: ChatUI.ToolMessage & { id: string; read: boolean };
isFirstOfRole: boolean;
showProgressLogs?: boolean;
user?: {
firstName: string;
lastName: string;
};
}
const props = defineProps<Props>();
const { t } = useI18n();
const expanded = ref(false);
const toolDisplayName = computed(() => {
// Convert tool names from snake_case to Title Case
return props.message.toolName
.split('_')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
});
const latestInput = computed(() => {
const inputUpdate = props.message.updates?.find((u) => u.type === 'input');
return inputUpdate?.data;
});
const latestOutput = computed(() => {
const outputUpdate = props.message.updates.find((u) => u.type === 'output');
return outputUpdate?.data;
});
const latestError = computed(() => {
const errorUpdate = props.message.updates.find((u) => u.type === 'error');
return errorUpdate?.data;
});
const progressMessages = computed(() => {
return (props.message.updates ?? []).filter((u) => u.type === 'progress').map((u) => u.data);
});
const statusMessage = computed(() => {
switch (props.message.status) {
case 'running':
return t('assistantChat.builder.toolRunning');
case 'completed':
return t('assistantChat.builder.toolCompleted');
case 'error':
return t('assistantChat.builder.toolError');
default:
return '';
}
});
const statusColor = computed(() => {
switch (props.message.status) {
case 'completed':
return 'success';
case 'error':
return 'danger';
default:
return 'secondary';
}
});
function formatJSON(data: Record<string, unknown> | string): string {
if (!data) return '';
try {
return JSON.stringify(data, null, 2);
} catch {
// eslint-disable-next-line @typescript-eslint/no-base-to-string
return String(data);
}
}
function toggleExpanded() {
expanded.value = !expanded.value;
}
</script>
<template>
<BaseMessage :message="message" :is-first-of-role="isFirstOfRole" :user="user">
<div :class="$style.toolMessage">
<div :class="$style.header" @click="toggleExpanded">
<div :class="$style.titleRow">
<N8nIcon
:icon="expanded ? 'chevron-down' : 'chevron-right'"
size="small"
:class="$style.expandIcon"
/>
<span :class="$style.toolName">{{ toolDisplayName }}</span>
<div :class="$style.status">
<N8nTooltip placement="left" :disabled="message.status !== 'running'">
<template #content>
<span :class="$style.statusText">
{{ statusMessage }}
</span>
</template>
<N8nIcon
v-if="message.status === 'running'"
icon="spinner"
spin
:color="statusColor"
/>
<N8nIcon
v-else-if="message.status === 'error'"
icon="status-error"
:color="statusColor"
/>
<N8nIcon v-else icon="status-completed" :color="statusColor" />
</N8nTooltip>
</div>
</div>
</div>
<div v-if="expanded" :class="$style.content">
<!-- Progress messages -->
<div v-if="progressMessages.length > 0 && showProgressLogs" :class="$style.section">
<div :class="$style.sectionTitle">Progress</div>
<div
v-for="(progress, index) in progressMessages"
:key="index"
:class="$style.progressItem"
>
{{ progress }}
</div>
</div>
<!-- Input -->
<div v-if="latestInput" :class="$style.section">
<div :class="$style.sectionTitle">Input</div>
<pre :class="$style.jsonContent">{{ formatJSON(latestInput) }}</pre>
</div>
<!-- Output -->
<div v-if="latestOutput" :class="$style.section">
<div :class="$style.sectionTitle">Output</div>
<pre :class="$style.jsonContent">{{ formatJSON(latestOutput) }}</pre>
</div>
<!-- Error -->
<div v-if="latestError" :class="$style.section">
<div :class="$style.sectionTitle">Error</div>
<div :class="$style.errorContent">
{{ latestError.message || latestError }}
</div>
</div>
</div>
</div>
</BaseMessage>
</template>
<style lang="scss" module>
.toolMessage {
width: 100%;
}
.header {
cursor: pointer;
padding: var(--spacing-xs);
border-radius: var(--border-radius-base);
background-color: var(--color-background-light);
&:hover {
background-color: var(--color-background-base);
}
}
.titleRow {
display: flex;
align-items: center;
gap: var(--spacing-xs);
}
.expandIcon {
flex-shrink: 0;
}
.toolName {
font-weight: var(--font-weight-bold);
flex: 1;
}
.status {
display: flex;
align-items: center;
gap: var(--spacing-3xs);
}
.statusText {
font-size: var(--font-size-2xs);
text-transform: capitalize;
&.status-running {
color: var(--execution-card-text-waiting);
}
&.status-completed {
color: var(--color-success);
}
&.status-error {
color: var(--color-text-danger);
}
}
.content {
margin-top: var(--spacing-xs);
padding: var(--spacing-xs);
background-color: var(--color-background-xlight);
border-radius: var(--border-radius-base);
}
.section {
margin-bottom: var(--spacing-s);
&:last-child {
margin-bottom: 0;
}
}
.sectionTitle {
font-weight: var(--font-weight-bold);
font-size: var(--font-size-2xs);
color: var(--color-text-dark);
margin-bottom: var(--spacing-3xs);
}
.progressItem {
font-size: var(--font-size-2xs);
color: var(--color-text-base);
margin-bottom: var(--spacing-3xs);
}
.jsonContent {
font-family: var(--font-family-monospace);
font-size: var(--font-size-3xs);
background-color: var(--color-background-base);
padding: var(--spacing-xs);
border-radius: var(--border-radius-base);
overflow-x: auto;
margin: 0;
max-height: 300px;
overflow-y: auto;
@supports not (selector(::-webkit-scrollbar)) {
scrollbar-width: thin;
}
@supports selector(::-webkit-scrollbar) {
&::-webkit-scrollbar {
width: var(--spacing-2xs);
height: var(--spacing-2xs);
}
&::-webkit-scrollbar-thumb {
border-radius: var(--spacing-xs);
background: var(--color-foreground-dark);
border: var(--spacing-5xs) solid white;
}
}
}
.errorContent {
color: var(--color-danger);
font-size: var(--font-size-2xs);
padding: var(--spacing-xs);
background-color: var(--color-danger-tint-2);
border-radius: var(--border-radius-base);
}
</style>

View File

@@ -1,71 +0,0 @@
<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

@@ -1,54 +0,0 @@
<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

@@ -1,124 +0,0 @@
<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';
import N8nButton from '../../../N8nButton';
import N8nInput from '../../../N8nInput';
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">
<N8nButton
type="secondary"
size="small"
:label="t('assistantChat.builder.thumbsUp')"
data-test-id="message-thumbs-up-button"
icon="thumbs-up"
@click="onRateButton('thumbsUp')"
/>
<N8nButton
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">
<N8nInput
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">
<N8nButton
native-type="submit"
type="secondary"
size="small"
data-test-id="message-submit-feedback-button"
@click="onSubmitFeedback"
>
{{ t('assistantChat.builder.submit') }}
</N8nButton>
</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

@@ -1,31 +0,0 @@
<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

@@ -1,48 +0,0 @@
<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

@@ -1,49 +0,0 @@
<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

@@ -3,6 +3,7 @@ import type { N8nLocale } from '@n8n/design-system/types';
export default {
'generic.retry': 'Retry',
'generic.cancel': 'Cancel',
'nds.auth.roles.owner': 'Owner',
'nds.userInfo.you': '(you)',
'nds.userSelect.selectUser': 'Select User',
@@ -45,6 +46,9 @@ export default {
'assistantChat.builder.selectedNodes': 'Selected workflow nodes',
'assistantChat.builder.selectingNodes': 'Selecting nodes...',
'assistantChat.builder.generatedNodes': 'Generated workflow nodes',
'assistantChat.builder.toolRunning': 'Tool running',
'assistantChat.builder.toolCompleted': 'Tool completed',
'assistantChat.builder.toolError': 'Tool completed with error',
'assistantChat.errorParsingMarkdown': 'Error parsing markdown content',
'assistantChat.aiAssistantLabel': 'AI Assistant',
'assistantChat.aiAssistantName': 'Assistant',

View File

@@ -1,5 +1,6 @@
export namespace ChatUI {
export interface TextMessage {
id?: string;
role: 'assistant' | 'user';
type: 'text';
content: string;
@@ -42,23 +43,6 @@ export namespace ChatUI {
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;
@@ -66,6 +50,7 @@ export namespace ChatUI {
}
export interface ErrorMessage {
id?: string;
role: 'assistant';
type: 'error';
content: string;
@@ -80,37 +65,24 @@ export namespace ChatUI {
suggestionId: string;
}
export interface WorkflowStepMessage {
export interface WorkflowUpdatedMessage {
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';
type: 'workflow-updated';
codeSnippet: string;
}
export interface RateWorkflowMessage {
export interface ToolMessage {
id?: string;
role: 'assistant';
type: 'rate-workflow';
content: string;
type: 'tool';
toolName: string;
toolCallId?: string;
status: 'running' | 'completed' | 'error';
updates: Array<{
type: 'input' | 'output' | 'progress' | 'error';
data: Record<string, unknown>;
timestamp?: string;
}>;
}
type MessagesWithReplies = (
@@ -123,19 +95,98 @@ export namespace ChatUI {
};
export type AssistantMessage = (
| TextMessage
| MessagesWithReplies
| ErrorMessage
| EndSessionMessage
| SessionTimeoutMessage
| SessionErrorMessage
| AgentSuggestionMessage
| WorkflowStepMessage
| WorkflowNodeMessage
| WorkflowComposedMessage
| WorkflowGeneratedMessage
| RateWorkflowMessage
| WorkflowUpdatedMessage
| ToolMessage
) & {
id: string;
read: boolean;
id?: string;
read?: boolean;
showRating?: boolean;
ratingStyle?: 'regular' | 'minimal';
showFeedback?: boolean;
};
}
export type RatingFeedback = { rating?: 'up' | 'down'; feedback?: string };
// Type guards for ChatUI messages
export function isTextMessage(
msg: ChatUI.AssistantMessage,
): msg is ChatUI.TextMessage & { id?: string; read?: boolean; quickReplies?: ChatUI.QuickReply[] } {
return msg.type === 'text';
}
export function isSummaryBlock(msg: ChatUI.AssistantMessage): msg is ChatUI.SummaryBlock & {
id?: string;
read?: boolean;
quickReplies?: ChatUI.QuickReply[];
} {
return msg.type === 'block';
}
export function isCodeDiffMessage(msg: ChatUI.AssistantMessage): msg is ChatUI.CodeDiffMessage & {
id?: string;
read?: boolean;
quickReplies?: ChatUI.QuickReply[];
} {
return msg.type === 'code-diff';
}
export function isErrorMessage(
msg: ChatUI.AssistantMessage,
): msg is ChatUI.ErrorMessage & { id?: string; read?: boolean } {
return msg.type === 'error';
}
export function isEndSessionMessage(
msg: ChatUI.AssistantMessage,
): msg is ChatUI.EndSessionMessage & { id?: string; read?: boolean } {
return msg.type === 'event' && msg.eventName === 'end-session';
}
export function isSessionTimeoutMessage(
msg: ChatUI.AssistantMessage,
): msg is ChatUI.SessionTimeoutMessage & { id?: string; read?: boolean } {
return msg.type === 'event' && msg.eventName === 'session-timeout';
}
export function isSessionErrorMessage(
msg: ChatUI.AssistantMessage,
): msg is ChatUI.SessionErrorMessage & { id?: string; read?: boolean } {
return msg.type === 'event' && msg.eventName === 'session-error';
}
export function isAgentSuggestionMessage(
msg: ChatUI.AssistantMessage,
): msg is ChatUI.AgentSuggestionMessage & {
id?: string;
read?: boolean;
quickReplies?: ChatUI.QuickReply[];
} {
return msg.type === 'agent-suggestion';
}
export function isWorkflowUpdatedMessage(
msg: ChatUI.AssistantMessage,
): msg is ChatUI.WorkflowUpdatedMessage & { id?: string; read?: boolean } {
return msg.type === 'workflow-updated';
}
export function isToolMessage(
msg: ChatUI.AssistantMessage,
): msg is ChatUI.ToolMessage & { id?: string; read?: boolean } {
return msg.type === 'tool';
}
// Helper to ensure message has required id and read properties
export function hasRequiredProps<T extends ChatUI.AssistantMessage>(
msg: T,
): msg is T & { id: string; read: boolean } {
return typeof msg.id === 'string' && typeof msg.read === 'boolean';
}

View File

@@ -179,6 +179,8 @@
"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.builder.workflowParsingError.title": "Unable to insert workflow",
"aiAssistant.builder.workflowParsingError.content": "The workflow returned by AI could not be parsed. Please try again.",
"aiAssistant.assistant": "AI Assistant",
"aiAssistant.newSessionModal.title.part1": "Start new",
"aiAssistant.newSessionModal.title.part2": "session",
@@ -192,6 +194,8 @@
"aiAssistant.codeUpdated.message.body2": "node to see the changes",
"aiAssistant.thinkingSteps.analyzingError": "Analyzing the error...",
"aiAssistant.thinkingSteps.thinking": "Thinking...",
"aiAssistant.thinkingSteps.runningTools": "Running tools...",
"aiAssistant.thinkingSteps.processingResults": "Processing results...",
"aiAssistant.prompts.currentView.workflowList": "The user is currently looking at the list of workflows.",
"aiAssistant.prompts.currentView.credentialsList": "The user is currently looking at the list of credentials.",
"aiAssistant.prompts.currentView.executionsView": "The user is currently looking at the list of executions for the currently open workflow.",

View File

@@ -81,3 +81,18 @@ export async function claimFreeAiCredits(
projectId,
} as IDataObject);
}
export async function getAiSessions(
ctx: IRestApiContext,
workflowId?: string,
): Promise<{
sessions: Array<{
sessionId: string;
messages: ChatRequest.MessageResponse[];
lastUpdated: string;
}>;
}> {
return await makeRestApiRequest(ctx, 'POST', '/ai/sessions', {
workflowId,
} as IDataObject);
}

View File

@@ -11,6 +11,7 @@ import AskAssistantBuild from './AskAssistantBuild.vue';
import { useBuilderStore } from '@/stores/builder.store';
import { mockedStore } from '@/__tests__/utils';
import { STORES } from '@n8n/stores';
import { useWorkflowsStore } from '@/stores/workflows.store';
vi.mock('@/event-bus', () => ({
nodeViewEventBus: {
@@ -34,11 +35,45 @@ vi.mock('@n8n/i18n', async (importOriginal) => ({
}),
}));
vi.mock('vue-router', () => {
const params = {};
const push = vi.fn();
const replace = vi.fn();
const resolve = vi.fn().mockImplementation(() => ({ href: '' }));
return {
useRoute: () => ({
params,
}),
useRouter: () => ({
push,
replace,
resolve,
}),
RouterLink: vi.fn(),
};
});
vi.mock('@/composables/useWorkflowSaving', () => ({
useWorkflowSaving: vi.fn().mockReturnValue({
getCurrentWorkflow: vi.fn(),
saveCurrentWorkflow: vi.fn(),
getWorkflowDataToSave: vi.fn(),
setDocumentTitle: vi.fn(),
executeData: vi.fn(),
getNodeTypes: vi.fn().mockReturnValue([]),
}),
}));
const workflowPrompt = 'Create a workflow';
describe('AskAssistantBuild', () => {
const sessionId = faker.string.uuid();
const renderComponent = createComponentRenderer(AskAssistantBuild);
let builderStore: ReturnType<typeof mockedStore<typeof useBuilderStore>>;
let workflowsStore: ReturnType<typeof mockedStore<typeof useWorkflowsStore>>;
beforeAll(() => {
Element.prototype.scrollTo = vi.fn(() => {});
});
beforeEach(() => {
vi.clearAllMocks();
@@ -57,13 +92,21 @@ describe('AskAssistantBuild', () => {
setActivePinia(pinia);
builderStore = mockedStore(useBuilderStore);
workflowsStore = mockedStore(useWorkflowsStore);
// Mock action implementations
builderStore.initBuilderChat = vi.fn();
builderStore.sendChatMessage = vi.fn();
builderStore.resetBuilderChat = vi.fn();
builderStore.addAssistantMessages = vi.fn();
builderStore.$onAction = vi.fn().mockReturnValue(vi.fn());
builderStore.applyWorkflowUpdate = vi
.fn()
.mockReturnValue({ success: true, workflowData: {}, newNodeIds: [] });
builderStore.getWorkflowSnapshot = vi.fn().mockReturnValue('{}');
builderStore.workflowMessages = [];
builderStore.toolMessages = [];
builderStore.workflowPrompt = workflowPrompt;
workflowsStore.workflowId = 'abc123';
});
describe('rendering', () => {
@@ -76,7 +119,7 @@ describe('AskAssistantBuild', () => {
renderComponent();
// Basic verification that no methods were called on mount
expect(builderStore.initBuilderChat).not.toHaveBeenCalled();
expect(builderStore.sendChatMessage).not.toHaveBeenCalled();
expect(builderStore.addAssistantMessages).not.toHaveBeenCalled();
});
});
@@ -97,106 +140,175 @@ describe('AskAssistantBuild', () => {
await flushPromises();
expect(builderStore.initBuilderChat).toHaveBeenCalledWith(testMessage, 'chat');
expect(builderStore.sendChatMessage).toHaveBeenCalledWith({ text: testMessage });
});
});
describe('feedback handling', () => {
const workflowJson = '{"nodes": [], "connections": {}}';
beforeEach(() => {
builderStore.chatMessages = [
{
id: faker.string.uuid(),
role: 'assistant',
type: 'workflow-generated',
read: true,
codeSnippet: workflowJson,
},
{
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();
describe('when workflow-updated message exists', () => {
beforeEach(() => {
// Use $patch to ensure reactivity
builderStore.$patch({
chatMessages: [
{
id: faker.string.uuid(),
role: 'assistant',
type: 'workflow-updated',
read: true,
codeSnippet: workflowJson,
},
{
id: faker.string.uuid(),
role: 'assistant',
type: 'text',
content: 'Wat',
read: true,
showRating: true,
ratingStyle: 'regular',
},
],
});
});
// Find thumbs up button in RateWorkflowMessage component
const thumbsUpButton = await findByTestId('message-thumbs-up-button');
thumbsUpButton.click();
it('should track feedback when user rates the workflow positively', async () => {
// Render component after setting up the store state
const { findByTestId } = renderComponent();
await flushPromises();
await flushPromises();
expect(trackMock).toHaveBeenCalledWith('User rated workflow generation', {
helpful: true,
prompt: 'Create a workflow',
workflow_json: workflowJson,
// Find thumbs up button in RateWorkflowMessage component
const thumbsUpButton = await findByTestId('message-thumbs-up-button');
await fireEvent.click(thumbsUpButton);
await flushPromises();
expect(trackMock).toHaveBeenCalledWith('User rated workflow generation', {
helpful: true,
workflow_id: 'abc123',
});
});
it('should track feedback when user rates the workflow negatively', async () => {
const { findByTestId } = renderComponent();
await flushPromises();
// Find thumbs down button in RateWorkflowMessage component
const thumbsDownButton = await findByTestId('message-thumbs-down-button');
await fireEvent.click(thumbsDownButton);
await flushPromises();
expect(trackMock).toHaveBeenCalledWith('User rated workflow generation', {
helpful: false,
workflow_id: 'abc123',
});
});
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({
feedback: feedbackText,
workflow_id: 'abc123',
}),
);
});
});
it('should track feedback when user rates the workflow negatively', async () => {
const { findByTestId } = renderComponent();
describe('when no workflow-updated message exists', () => {
beforeEach(() => {
builderStore.$patch({
chatMessages: [
{
id: faker.string.uuid(),
role: 'assistant',
type: 'text',
content: 'This is just an informational message',
read: true,
showRating: false,
},
],
});
});
// Find thumbs down button in RateWorkflowMessage component
const thumbsDownButton = await findByTestId('message-thumbs-down-button');
thumbsDownButton.click();
it('should not show rating buttons when no workflow update occurred', async () => {
const { queryAllByTestId } = renderComponent();
await flushPromises();
await flushPromises();
expect(trackMock).toHaveBeenCalledWith('User rated workflow generation', {
helpful: false,
prompt: 'Create a workflow',
workflow_json: workflowJson,
// Rating buttons should not be present
expect(queryAllByTestId('message-thumbs-up-button')).toHaveLength(0);
expect(queryAllByTestId('message-thumbs-down-button')).toHaveLength(0);
});
});
it('should track text feedback when submitted', async () => {
const { findByTestId } = renderComponent();
describe('when tools are still running', () => {
beforeEach(() => {
builderStore.$patch({
chatMessages: [
{
id: faker.string.uuid(),
role: 'assistant',
type: 'tool',
toolName: 'add_nodes',
status: 'running',
updates: [],
read: true,
},
{
id: faker.string.uuid(),
role: 'assistant',
type: 'workflow-updated',
read: true,
codeSnippet: workflowJson,
},
{
id: faker.string.uuid(),
role: 'assistant',
type: 'text',
content: 'Working on your workflow...',
read: true,
showRating: true,
ratingStyle: 'minimal',
},
],
});
});
const feedbackText = 'This workflow is great but could be improved';
it('should show minimal rating style when tools are still running', async () => {
const { findByTestId } = renderComponent();
// Click thumbs down to show feedback form
const thumbsDownButton = await findByTestId('message-thumbs-down-button');
thumbsDownButton.click();
await flushPromises();
await flushPromises();
// Check that rating buttons exist but in minimal style
const thumbsUpButton = await findByTestId('message-thumbs-up-button');
expect(thumbsUpButton).toBeInTheDocument();
// 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({
feedback: feedbackText,
prompt: 'Create a workflow',
workflow_json: workflowJson,
}),
);
});
});
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();
// The minimal style should have icon-only buttons (no label)
expect(thumbsUpButton.textContent).toBe('');
});
});
});
});

View File

@@ -1,14 +1,16 @@
<script lang="ts" setup>
import { useBuilderStore } from '@/stores/builder.store';
import { useUsersStore } from '@/stores/users.store';
import { computed, watch, ref, onBeforeUnmount } from 'vue';
import { computed, watch, ref } from 'vue';
import AskAssistantChat from '@n8n/design-system/components/AskAssistantChat/AskAssistantChat.vue';
import { useTelemetry } from '@/composables/useTelemetry';
import type { WorkflowDataUpdate } from '@n8n/rest-api-client/api/workflows';
import { nodeViewEventBus } from '@/event-bus';
import { v4 as uuid } from 'uuid';
import { useI18n } from '@n8n/i18n';
import { STICKY_NODE_TYPE } from '@/constants';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { useRoute, useRouter } from 'vue-router';
import { useWorkflowSaving } from '@/composables/useWorkflowSaving';
import type { RatingFeedback } from '@n8n/design-system/types/assistant';
import { isWorkflowUpdatedMessage } from '@n8n/design-system/types/assistant';
import { nodeViewEventBus } from '@/event-bus';
const emit = defineEmits<{
close: [];
@@ -17,151 +19,104 @@ const emit = defineEmits<{
const builderStore = useBuilderStore();
const usersStore = useUsersStore();
const telemetry = useTelemetry();
const workflowsStore = useWorkflowsStore();
const i18n = useI18n();
const helpful = ref(false);
const generationStartTime = ref(0);
const route = useRoute();
const router = useRouter();
const workflowSaver = useWorkflowSaving({ router });
// Track processed workflow updates
const processedWorkflowUpdates = ref(new Set<string>());
const trackedTools = ref(new Set<string>());
const user = computed(() => ({
firstName: usersStore.currentUser?.firstName ?? '',
lastName: usersStore.currentUser?.lastName ?? '',
}));
const workflowGenerated = ref(false);
const loadingMessage = computed(() => builderStore.assistantThinkingMessage);
const generatedWorkflowJson = computed(
() => builderStore.chatMessages.find((msg) => msg.type === 'workflow-generated')?.codeSnippet,
);
const currentRoute = computed(() => route.name);
async function onUserMessage(content: string) {
// If there is no current session running, initialize the support chat session
await builderStore.initBuilderChat(content, 'chat');
}
const isNewWorkflow = workflowsStore.isNewWorkflow;
function fixWorkflowStickiesPosition(workflowData: WorkflowDataUpdate): WorkflowDataUpdate {
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: WorkflowDataUpdate;
try {
workflowData = JSON.parse(code);
} catch (error) {
console.error('Error parsing workflow data', error);
return;
// Save the workflow to get workflow ID which is used for session
if (isNewWorkflow) {
await workflowSaver.saveCurrentWorkflow();
}
telemetry.track('Workflow generated from prompt', {
prompt: builderStore.workflowPrompt,
latency: new Date().getTime() - generationStartTime.value,
workflow_json: generatedWorkflowJson.value,
});
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', {
helpful: helpful.value,
prompt: builderStore.workflowPrompt,
workflow_json: generatedWorkflowJson.value,
});
}
function onThumbsDown() {
helpful.value = false;
telemetry.track('User rated workflow generation', {
helpful: helpful.value,
prompt: builderStore.workflowPrompt,
workflow_json: generatedWorkflowJson.value,
});
}
function onSubmitFeedback(feedback: string) {
telemetry.track('User submitted workflow generation feedback', {
helpful: helpful.value,
feedback,
prompt: builderStore.workflowPrompt,
workflow_json: generatedWorkflowJson.value,
});
builderStore.sendChatMessage({ text: content });
}
// Watch for workflow updates and apply them
watch(
() => builderStore.chatMessages,
() => builderStore.workflowMessages,
(messages) => {
if (workflowGenerated.value) return;
messages
.filter((msg) => {
return msg.id && !processedWorkflowUpdates.value.has(msg.id);
})
.forEach((msg) => {
if (msg.id && isWorkflowUpdatedMessage(msg)) {
processedWorkflowUpdates.value.add(msg.id);
const workflowGeneratedMessage = messages.find((msg) => msg.type === 'workflow-generated');
if (workflowGeneratedMessage) {
onInsertWorkflow(workflowGeneratedMessage.codeSnippet);
}
const currentWorkflowJson = builderStore.getWorkflowSnapshot();
const result = builderStore.applyWorkflowUpdate(msg.codeSnippet);
if (result.success) {
// Import the updated workflow
nodeViewEventBus.emit('importWorkflowData', {
data: result.workflowData,
tidyUp: true,
nodesIdsToTidyUp: result.newNodeIds,
regenerateIds: false,
});
// Track tool usage for telemetry
const newToolMessages = builderStore.toolMessages.filter(
(toolMsg) =>
toolMsg.status !== 'running' &&
toolMsg.toolCallId &&
!trackedTools.value.has(toolMsg.toolCallId),
);
newToolMessages.forEach((toolMsg) => trackedTools.value.add(toolMsg.toolCallId ?? ''));
telemetry.track('Workflow modified by builder', {
tools_called: newToolMessages.map((toolMsg) => toolMsg.toolName),
start_workflow_json: currentWorkflowJson,
end_workflow_json: msg.codeSnippet,
workflow_id: workflowsStore.workflowId,
});
}
}
});
},
{ deep: true },
);
const unsubscribe = builderStore.$onAction(({ name }) => {
if (name === 'initBuilderChat') {
onNewWorkflow();
}
});
function onNewWorkflow() {
builderStore.resetBuilderChat();
processedWorkflowUpdates.value.clear();
trackedTools.value.clear();
}
onBeforeUnmount(() => {
unsubscribe();
function onFeedback(feedback: RatingFeedback) {
if (feedback.rating) {
telemetry.track('User rated workflow generation', {
helpful: feedback.rating === 'up',
workflow_id: workflowsStore.workflowId,
});
}
if (feedback.feedback) {
telemetry.track('User submitted workflow generation feedback', {
feedback: feedback.feedback,
workflow_id: workflowsStore.workflowId,
});
}
}
// Reset on route change
watch(currentRoute, () => {
onNewWorkflow();
});
</script>
@@ -172,16 +127,13 @@ onBeforeUnmount(() => {
:messages="builderStore.chatMessages"
:streaming="builderStore.streaming"
:loading-message="loadingMessage"
:session-id="builderStore.currentSessionId"
:mode="i18n.baseText('aiAssistant.builder.mode')"
:title="'n8n AI'"
:scroll-on-new-message="true"
:placeholder="i18n.baseText('aiAssistant.builder.placeholder')"
@close="emit('close')"
@message="onUserMessage"
@thumbs-up="onThumbsUp"
@thumbs-down="onThumbsDown"
@submit-feedback="onSubmitFeedback"
@insert-workflow="onInsertWorkflow"
@feedback="onFeedback"
>
<template #header>
<slot name="header" />
@@ -191,21 +143,6 @@ onBeforeUnmount(() => {
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>

View File

@@ -28,7 +28,7 @@ function onResizeDebounced(data: { direction: string; x: number; width: number }
function toggleAssistantMode() {
isBuildMode.value = !isBuildMode.value;
if (isBuildMode.value) {
builderStore.openChat();
void builderStore.openChat();
} else {
assistantStore.openChat();
}
@@ -50,7 +50,7 @@ const unsubscribeAssistantStore = assistantStore.$onAction(({ name }) => {
const unsubscribeBuilderStore = builderStore.$onAction(({ name }) => {
// When assistant is opened from error or credentials help
// switch from build mode to chat mode
if (name === 'initBuilderChat') {
if (name === 'sendChatMessage') {
isBuildMode.value = true;
}
});

View File

@@ -714,7 +714,11 @@ async function onContextMenuAction(action: ContextMenuAction, nodeIds: string[])
}
}
async function onTidyUp(payload: { source: CanvasLayoutSource }) {
async function onTidyUp(payload: { source: CanvasLayoutSource; nodeIdsFilter?: string[] }) {
if (payload.nodeIdsFilter && payload.nodeIdsFilter.length > 0) {
clearSelectedNodes();
addSelectedNodes(payload.nodeIdsFilter.map(findNode).filter(isPresent));
}
const applyOnSelection = selectedNodes.value.length > 1;
const target = applyOnSelection ? 'selection' : 'all';
const result = layout(target);

View File

@@ -3,15 +3,21 @@ import { ref, computed } from 'vue';
import { useI18n } from '@n8n/i18n';
import { useCanvasNode } from '@/composables/useCanvasNode';
import { useBuilderStore } from '@/stores/builder.store';
import { useRouter } from 'vue-router';
import { useWorkflowSaving } from '@/composables/useWorkflowSaving';
import { useWorkflowsStore } from '@/stores/workflows.store';
const emit = defineEmits<{
delete: [id: string];
}>();
const i18n = useI18n();
const router = useRouter();
const { id } = useCanvasNode();
const builderStore = useBuilderStore();
const workflowsStore = useWorkflowsStore();
const workflowSaver = useWorkflowSaving({ router });
const isPromptVisible = ref(true);
const isFocused = ref(false);
@@ -19,9 +25,17 @@ const prompt = ref('');
const hasContent = computed(() => prompt.value.trim().length > 0);
async function onSubmit() {
builderStore.openChat();
const isNewWorkflow = workflowsStore.isNewWorkflow;
// Save the workflow to get workflow ID which is used for session
if (isNewWorkflow) {
await workflowSaver.saveCurrentWorkflow();
}
// Here we need to await for chat to open and session to be loaded
await builderStore.openChat();
emit('delete', id.value);
await builderStore.initBuilderChat(prompt.value, 'canvas');
builderStore.sendChatMessage({ text: prompt.value, source: 'canvas' });
isPromptVisible.value = false;
}
</script>

View File

@@ -180,7 +180,7 @@ export const useAIAssistantHelpers = () => {
* @param nodeNames The names of the nodes to get the schema for
* @returns An array of NodeExecutionSchema objects
*/
function getNodesSchemas(nodeNames: string[]) {
function getNodesSchemas(nodeNames: string[], excludeValues?: boolean) {
const schemas: ChatRequest.NodeExecutionSchema[] = [];
for (const name of nodeNames) {
const node = workflowsStore.getNodeByName(name);
@@ -188,7 +188,10 @@ export const useAIAssistantHelpers = () => {
continue;
}
const { getSchemaForExecutionData, getInputDataWithPinned } = useDataSchema();
const schema = getSchemaForExecutionData(executionDataToJson(getInputDataWithPinned(node)));
const schema = getSchemaForExecutionData(
executionDataToJson(getInputDataWithPinned(node)),
excludeValues,
);
schemas.push({
nodeName: node.name,
schema,

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,354 @@
import type { ChatUI } from '@n8n/design-system/types/assistant';
import type { ChatRequest } from '@/types/assistant.types';
import { useI18n } from '@n8n/i18n';
import { isTextMessage, isWorkflowUpdatedMessage, isToolMessage } from '@/types/assistant.types';
export interface MessageProcessingResult {
messages: ChatUI.AssistantMessage[];
thinkingMessage?: string;
shouldClearThinking: boolean;
}
export function useBuilderMessages() {
const locale = useI18n();
/**
* Apply rating logic to messages - only show rating on the last AI text message after workflow-updated
* when no tools are running
*/
function applyRatingLogic(messages: ChatUI.AssistantMessage[]): ChatUI.AssistantMessage[] {
// Check if any tools are still running
const hasRunningTools = messages.some(
(m) => m.type === 'tool' && (m as ChatUI.ToolMessage).status === 'running',
);
// Don't apply rating if tools are still running
if (hasRunningTools) {
// Remove any existing ratings
return messages.map((message) => {
if (message.type === 'text' && 'showRating' in message) {
// Pick all properties except showRating and ratingStyle
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { showRating, ratingStyle, ...cleanMessage } = message as ChatUI.TextMessage & {
showRating?: boolean;
ratingStyle?: string;
};
return cleanMessage;
}
return message;
});
}
// Find the index of the last workflow-updated message
let lastWorkflowUpdateIndex = -1;
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].type === 'workflow-updated') {
lastWorkflowUpdateIndex = i;
break;
}
}
// If no workflow-updated, return messages as-is
if (lastWorkflowUpdateIndex === -1) {
return messages;
}
// Find the last assistant text message after workflow-updated
let lastAssistantTextIndex = -1;
for (let i = messages.length - 1; i >= 0; i--) {
if (
messages[i].type === 'text' &&
messages[i].role === 'assistant' &&
i > lastWorkflowUpdateIndex
) {
lastAssistantTextIndex = i;
break;
}
}
// Apply rating only to the last assistant text message after workflow-updated
return messages.map((message, index) => {
if (
message.type === 'text' &&
message.role === 'assistant' &&
index === lastAssistantTextIndex
) {
return {
...message,
showRating: true,
ratingStyle: 'regular',
};
}
// Remove any existing rating from other messages
if (message.type === 'text' && 'showRating' in message) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { showRating, ratingStyle, ...cleanMessage } = message as ChatUI.TextMessage & {
showRating?: boolean;
ratingStyle?: string;
};
return cleanMessage;
}
return message;
});
}
/**
* Process a single message and add it to the messages array
*/
function processSingleMessage(
messages: ChatUI.AssistantMessage[],
msg: ChatRequest.MessageResponse,
messageId: string,
): boolean {
let shouldClearThinking = false;
if (isTextMessage(msg)) {
messages.push({
id: messageId,
role: 'assistant',
type: 'text',
content: msg.text,
read: false,
} as ChatUI.AssistantMessage);
shouldClearThinking = true;
} else if (isWorkflowUpdatedMessage(msg)) {
messages.push({
...msg,
id: messageId,
read: false,
} as ChatUI.AssistantMessage);
// Don't clear thinking for workflow updates - they're just state changes
} else if (isToolMessage(msg)) {
processToolMessage(messages, msg, messageId);
} else if ('type' in msg && msg.type === 'error' && 'content' in msg) {
// Handle error messages from the API
// API sends error messages with type: 'error' and content field
messages.push({
id: messageId,
role: 'assistant',
type: 'error',
content: msg.content,
read: false,
});
shouldClearThinking = true;
}
return shouldClearThinking;
}
/**
* Process a tool message - either update existing or add new
*/
function processToolMessage(
messages: ChatUI.AssistantMessage[],
msg: ChatRequest.ToolMessage,
messageId: string,
): void {
// Use toolCallId as the message ID for consistency across updates
const toolMessageId = msg.toolCallId || messageId;
// Check if we already have this tool message
const existingIndex = msg.toolCallId
? messages.findIndex(
(m) => m.type === 'tool' && (m as ChatUI.ToolMessage).toolCallId === msg.toolCallId,
)
: -1;
if (existingIndex !== -1) {
// Update existing tool message - merge updates array
const existing = messages[existingIndex] as ChatUI.ToolMessage;
const toolMessage: ChatUI.ToolMessage = {
...existing,
status: msg.status,
updates: [...(existing.updates || []), ...(msg.updates || [])],
};
messages[existingIndex] = toolMessage as ChatUI.AssistantMessage;
} else {
// Add new tool message
const toolMessage: ChatUI.AssistantMessage = {
id: toolMessageId,
role: 'assistant',
type: 'tool',
toolName: msg.toolName,
toolCallId: msg.toolCallId,
status: msg.status,
updates: msg.updates || [],
read: false,
};
messages.push(toolMessage);
}
}
/**
* Determine the thinking message based on tool states
*/
function determineThinkingMessage(messages: ChatUI.AssistantMessage[]): string | undefined {
// Check ALL messages to determine state
const allToolMessages = messages.filter(
(msg): msg is ChatUI.ToolMessage => msg.type === 'tool',
);
const hasAnyRunningTools = allToolMessages.some((msg) => msg.status === 'running');
const hasCompletedTools = allToolMessages.some((msg) => msg.status === 'completed');
// Find the last completed tool message
let lastCompletedToolIndex = -1;
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i];
if (msg.type === 'tool' && (msg as ChatUI.ToolMessage).status === 'completed') {
lastCompletedToolIndex = i;
break;
}
}
// Check if there's any text message after the last completed tool
// Note: workflow-updated messages shouldn't count as they're just canvas state updates
let hasTextAfterTools = false;
if (lastCompletedToolIndex !== -1) {
for (let i = lastCompletedToolIndex + 1; i < messages.length; i++) {
const msg = messages[i];
if (msg.type === 'text') {
hasTextAfterTools = true;
break;
}
}
}
// - If any tools are running, show "Running tools..."
// - If all tools are done and no text response yet, show "Processing results..."
// - Otherwise, clear the thinking message
if (hasAnyRunningTools) {
return locale.baseText('aiAssistant.thinkingSteps.runningTools');
} else if (hasCompletedTools && !hasTextAfterTools) {
return locale.baseText('aiAssistant.thinkingSteps.processingResults');
}
return undefined;
}
function processAssistantMessages(
currentMessages: ChatUI.AssistantMessage[],
newMessages: ChatRequest.MessageResponse[],
baseId: string,
): MessageProcessingResult {
const mutableMessages = [...currentMessages];
let shouldClearThinking = false;
newMessages.forEach((msg, index) => {
// Generate unique ID for each message in the batch
const messageId = `${baseId}-${index}`;
const clearThinking = processSingleMessage(mutableMessages, msg, messageId);
shouldClearThinking = shouldClearThinking || clearThinking;
});
const thinkingMessage = determineThinkingMessage(mutableMessages);
// Apply rating logic only to messages after workflow-updated
const finalMessages = applyRatingLogic(mutableMessages);
return {
messages: finalMessages,
thinkingMessage,
shouldClearThinking: shouldClearThinking && mutableMessages.length > currentMessages.length,
};
}
function createUserMessage(content: string, id: string): ChatUI.AssistantMessage {
return {
id,
role: 'user',
type: 'text',
content,
read: true,
} as ChatUI.AssistantMessage;
}
function createErrorMessage(
content: string,
id: string,
retry?: () => Promise<void>,
): ChatUI.AssistantMessage {
return {
id,
role: 'assistant',
type: 'error',
content,
retry,
read: false,
} as ChatUI.AssistantMessage;
}
function clearMessages(): ChatUI.AssistantMessage[] {
return [];
}
function addMessages(
currentMessages: ChatUI.AssistantMessage[],
newMessages: ChatUI.AssistantMessage[],
): ChatUI.AssistantMessage[] {
return [...currentMessages, ...newMessages];
}
function mapAssistantMessageToUI(
message: ChatRequest.MessageResponse,
id: string,
): ChatUI.AssistantMessage {
// Handle specific message types using type guards
if (isTextMessage(message)) {
return {
id,
role: message.role ?? 'assistant',
type: 'text',
content: message.text,
read: false,
} as ChatUI.AssistantMessage;
}
if (isWorkflowUpdatedMessage(message)) {
return {
...message,
id,
read: false,
} as ChatUI.AssistantMessage;
}
if (isToolMessage(message)) {
return {
id,
role: 'assistant',
type: 'tool',
toolName: message.toolName,
toolCallId: message.toolCallId,
status: message.status,
updates: message.updates || [],
read: false,
} as ChatUI.AssistantMessage;
}
// Handle event messages
if ('type' in message && message.type === 'event') {
return {
...message,
id,
read: false,
} as ChatUI.AssistantMessage;
}
// Default fallback
return {
id,
role: 'assistant',
type: 'text',
content: locale.baseText('aiAssistant.thinkingSteps.thinking'),
read: false,
} as ChatUI.AssistantMessage;
}
return {
processAssistantMessages,
createUserMessage,
createErrorMessage,
clearMessages,
addMessages,
mapAssistantMessageToUI,
};
}

View File

@@ -1835,10 +1835,12 @@ export function useCanvasOperations() {
trackBulk = true,
trackHistory = true,
viewport,
regenerateIds = true,
}: {
importTags?: boolean;
trackBulk?: boolean;
trackHistory?: boolean;
regenerateIds?: boolean;
viewport?: ViewportBoundaries;
} = {},
): Promise<WorkflowDataUpdate> {
@@ -1884,8 +1886,10 @@ export function useCanvasOperations() {
// Set all new ids when pasting/importing workflows
if (node.id) {
const previousId = node.id;
const newId = nodeHelpers.assignNodeId(node);
nodeIdMap[newId] = previousId;
if (regenerateIds) {
const newId = nodeHelpers.assignNodeId(node);
nodeIdMap[newId] = previousId;
}
} else {
nodeHelpers.assignNodeId(node);
}

View File

@@ -738,7 +738,7 @@ export const NDV_UI_OVERHAUL_EXPERIMENT = {
};
export const WORKFLOW_BUILDER_EXPERIMENT = {
name: '30_workflow_builder',
name: '036_workflow_builder_agent',
control: 'control',
variant: 'variant',
};

View File

@@ -0,0 +1,56 @@
import type { ChatRequest } from '@/types/assistant.types';
import { useAIAssistantHelpers } from '@/composables/useAIAssistantHelpers';
import type { IRunExecutionData, NodeExecutionSchema } from 'n8n-workflow';
import type { IWorkflowDb } from '@/Interface';
export function generateMessageId(): string {
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
}
export function createBuilderPayload(
text: string,
options: {
quickReplyType?: string;
executionData?: IRunExecutionData['resultData'];
workflow?: IWorkflowDb;
nodesForSchema?: string[];
} = {},
): ChatRequest.UserChatMessage {
const assistantHelpers = useAIAssistantHelpers();
const workflowContext: {
currentWorkflow?: Partial<IWorkflowDb>;
executionData?: IRunExecutionData['resultData'];
executionSchema?: NodeExecutionSchema[];
} = {};
if (options.workflow) {
workflowContext.currentWorkflow = {
...assistantHelpers.simplifyWorkflowForAssistant(options.workflow),
id: options.workflow.id,
};
}
if (options.executionData) {
workflowContext.executionData = assistantHelpers.simplifyResultData(options.executionData);
}
if (options.nodesForSchema?.length) {
workflowContext.executionSchema = assistantHelpers.getNodesSchemas(
options.nodesForSchema,
true,
);
}
return {
role: 'user',
type: 'message',
text,
quickReplyType: options.quickReplyType,
workflowContext,
};
}
export function shouldShowChat(routeName: string): boolean {
const ENABLED_VIEWS = ['workflow', 'workflowExecution'];
return ENABLED_VIEWS.includes(routeName);
}

View File

@@ -30,6 +30,7 @@ import { useUIStore } from './ui.store';
import AiUpdatedCodeMessage from '@/components/AiUpdatedCodeMessage.vue';
import { useCredentialsStore } from './credentials.store';
import { useAIAssistantHelpers } from '@/composables/useAIAssistantHelpers';
import { useBuilderStore } from './builder.store';
export const MAX_CHAT_WIDTH = 425;
export const MIN_CHAT_WIDTH = 300;
@@ -62,6 +63,7 @@ export const useAssistantStore = defineStore(STORES.ASSISTANT, () => {
const locale = useI18n();
const telemetry = useTelemetry();
const assistantHelpers = useAIAssistantHelpers();
const builderStore = useBuilderStore();
const suggestions = ref<{
[suggestionId: string]: {
@@ -170,6 +172,11 @@ export const useAssistantStore = defineStore(STORES.ASSISTANT, () => {
if (chatWindowOpen.value) {
closeChat();
} else {
if (builderStore.isAIBuilderEnabled) {
// If builder is enabled, open it instead of assistant
void builderStore.openChat();
return;
}
openChat();
}
}
@@ -180,7 +187,7 @@ export const useAssistantStore = defineStore(STORES.ASSISTANT, () => {
(msg) => !(msg.id === id && msg.role === 'assistant'),
);
assistantThinkingMessage.value = undefined;
newMessages.forEach((msg) => {
(newMessages ?? []).forEach((msg) => {
if (msg.type === 'message') {
messages.push({
id,

View File

@@ -1,7 +1,6 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { describe, it, expect, beforeEach, vi } 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';
@@ -69,6 +68,12 @@ describe('AI Builder store', () => {
track.mockReset();
});
afterEach(() => {
vi.clearAllMocks();
vi.clearAllTimers();
vi.useRealTimers();
});
it('initializes with default values', () => {
const builderStore = useBuilderStore();
@@ -99,10 +104,10 @@ describe('AI Builder store', () => {
expect(builderStore.chatWidth).toBe(MAX_CHAT_WIDTH);
});
it('should open chat window', () => {
it('should open chat window', async () => {
const builderStore = useBuilderStore();
builderStore.openChat();
await builderStore.openChat();
expect(builderStore.chatWindowOpen).toBe(true);
});
@@ -113,101 +118,236 @@ describe('AI Builder store', () => {
expect(builderStore.chatWindowOpen).toBe(false);
});
it('can add a simple assistant message', () => {
it('can process a simple assistant message through API', async () => {
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',
apiSpy.mockImplementationOnce((_ctx, _payload, onMessage, onDone) => {
onMessage({
messages: [
{
type: 'message',
role: 'assistant',
text: 'Hello!',
},
],
sessionId: 'test-session',
});
onDone();
});
builderStore.sendChatMessage({ text: 'Hi' });
await vi.waitFor(() => expect(builderStore.chatMessages.length).toBe(2));
expect(builderStore.chatMessages[0].role).toBe('user');
expect(builderStore.chatMessages[1]).toMatchObject({
type: 'text',
role: 'assistant',
content: 'Hello!',
quickReplies: undefined,
read: true, // Builder messages are always read
read: false,
});
});
it('can add a workflow step message', () => {
it('can process a workflow-updated message through API', async () => {
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,
apiSpy.mockImplementationOnce((_ctx, _payload, onMessage, onDone) => {
onMessage({
messages: [
{
type: 'workflow-updated',
role: 'assistant',
codeSnippet: '{"nodes":[],"connections":[]}',
},
],
sessionId: 'test-session',
});
onDone();
});
});
it('can add a workflow-generated message', () => {
const builderStore = useBuilderStore();
const message: ChatRequest.MessageResponse = {
type: 'workflow-generated',
builderStore.sendChatMessage({ text: 'Create workflow' });
await vi.waitFor(() => expect(builderStore.chatMessages.length).toBe(2));
expect(builderStore.chatMessages[1]).toMatchObject({
type: 'workflow-updated',
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,
read: false,
});
// Verify workflow messages are accessible via computed property
expect(builderStore.workflowMessages.length).toBe(1);
});
it('can add a rate-workflow message', () => {
it('should show processing results message when tools complete', async () => {
vi.useFakeTimers();
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,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let onMessageCallback: any;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let onDoneCallback: any;
apiSpy.mockImplementationOnce((_ctx, _payload, onMessage, onDone) => {
onMessageCallback = onMessage;
onDoneCallback = onDone;
});
});
it('should reset builder chat session', () => {
const builderStore = useBuilderStore();
builderStore.sendChatMessage({ text: 'Add nodes and connect them' });
const message: ChatRequest.MessageResponse = {
type: 'message',
role: 'assistant',
text: 'Hello!',
quickReplies: [
{ text: 'Yes', type: 'text' },
{ text: 'No', type: 'text' },
// Initially shows "Thinking..." from prepareForStreaming
expect(builderStore.assistantThinkingMessage).toBe('Thinking...');
// First tool starts
onMessageCallback({
messages: [
{
type: 'tool',
role: 'assistant',
toolName: 'add_nodes',
toolCallId: 'call-1',
status: 'running',
updates: [{ type: 'input', data: {} }],
},
],
};
builderStore.addAssistantMessages([message], '1');
expect(builderStore.chatMessages.length).toBe(1);
});
// Should show "Running tools..."
expect(builderStore.assistantThinkingMessage).toBe('Running tools...');
// Second tool starts (different toolCallId)
onMessageCallback({
messages: [
{
type: 'tool',
role: 'assistant',
toolName: 'connect_nodes',
toolCallId: 'call-2',
status: 'running',
updates: [{ type: 'input', data: {} }],
},
],
});
// Still showing "Running tools..." with multiple tools
expect(builderStore.assistantThinkingMessage).toBe('Running tools...');
// First tool completes
onMessageCallback({
messages: [
{
type: 'tool',
role: 'assistant',
toolName: 'add_nodes',
toolCallId: 'call-1',
status: 'completed',
updates: [{ type: 'output', data: { success: true } }],
},
],
});
// Still "Running tools..." because second tool is still running
expect(builderStore.assistantThinkingMessage).toBe('Running tools...');
// Second tool completes
onMessageCallback({
messages: [
{
type: 'tool',
role: 'assistant',
toolName: 'connect_nodes',
toolCallId: 'call-2',
status: 'completed',
updates: [{ type: 'output', data: { success: true } }],
},
],
});
// Now should show "Processing results..." because all tools completed
expect(builderStore.assistantThinkingMessage).toBe('Processing results...');
// Call onDone to stop streaming
onDoneCallback();
// Message should persist after streaming ends
expect(builderStore.streaming).toBe(false);
expect(builderStore.assistantThinkingMessage).toBe('Processing results...');
vi.useRealTimers();
});
it('should keep processing message when workflow-updated arrives', async () => {
const builderStore = useBuilderStore();
apiSpy.mockImplementationOnce((_ctx, _payload, onMessage, onDone) => {
// Tool completes
onMessage({
messages: [
{
type: 'tool',
role: 'assistant',
toolName: 'add_nodes',
toolCallId: 'call-1',
status: 'completed',
updates: [{ type: 'output', data: { success: true } }],
},
],
});
// Workflow update arrives
onMessage({
messages: [
{
type: 'workflow-updated',
role: 'assistant',
codeSnippet: '{"nodes": [], "connections": {}}',
},
],
});
// Call onDone to stop streaming
onDone();
});
builderStore.sendChatMessage({ text: 'Add a node' });
// Should show "Processing results..." when tool completes
await vi.waitFor(() =>
expect(builderStore.assistantThinkingMessage).toBe('Processing results...'),
);
// Should still show "Processing results..." after workflow-updated
await vi.waitFor(() => expect(builderStore.chatMessages).toHaveLength(3)); // user + tool + workflow
expect(builderStore.assistantThinkingMessage).toBe('Processing results...');
// Verify streaming has ended
expect(builderStore.streaming).toBe(false);
});
it('should reset builder chat session', async () => {
const builderStore = useBuilderStore();
apiSpy.mockImplementationOnce((_ctx, _payload, onMessage, onDone) => {
onMessage({
messages: [
{
type: 'message',
role: 'assistant',
text: 'Hello!',
quickReplies: [
{ text: 'Yes', type: 'text' },
{ text: 'No', type: 'text' },
],
},
],
sessionId: 'test-session',
});
onDone();
});
builderStore.sendChatMessage({ text: 'Hi' });
await vi.waitFor(() => expect(builderStore.chatMessages.length).toBe(2));
builderStore.resetBuilderChat();
expect(builderStore.chatMessages).toEqual([]);
expect(builderStore.currentSessionId).toBeUndefined();
expect(builderStore.assistantThinkingMessage).toBeUndefined();
});
it('should not show builder if disabled in settings', () => {
@@ -259,13 +399,13 @@ describe('AI Builder store', () => {
onDone();
});
await builderStore.initBuilderChat('I want to build a workflow', 'chat');
builderStore.sendChatMessage({ text: 'I want to build a workflow' });
await vi.waitFor(() => expect(builderStore.chatMessages.length).toBe(2));
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');
expect(builderStore.streaming).toBe(false);
});
it('should send a follow-up message in an existing session', async () => {
@@ -302,18 +442,15 @@ describe('AI Builder store', () => {
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);
builderStore.sendChatMessage({ text: 'I want to build a workflow' });
await vi.waitFor(() => expect(builderStore.chatMessages.length).toBe(2));
// Send a follow-up message
await builderStore.sendMessage({ text: 'Generate a workflow for me' });
builderStore.sendChatMessage({ text: 'Generate a workflow for me' });
await vi.waitFor(() => expect(builderStore.chatMessages.length).toBe(4));
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');
@@ -330,10 +467,8 @@ describe('AI Builder store', () => {
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);
builderStore.sendChatMessage({ text: 'I want to build a workflow' });
await vi.waitFor(() => expect(builderStore.chatMessages.length).toBe(2));
expect(builderStore.chatMessages[0].role).toBe('user');
expect(builderStore.chatMessages[1].type).toBe('error');
@@ -341,6 +476,9 @@ describe('AI Builder store', () => {
const errorMessage = builderStore.chatMessages[1] as ChatUI.ErrorMessage;
expect(errorMessage.retry).toBeDefined();
// Verify streaming state was reset
expect(builderStore.streaming).toBe(false);
// Set up a successful response for the retry
apiSpy.mockImplementationOnce((_ctx, _payload, onMessage, onDone) => {
onMessage({
@@ -357,10 +495,10 @@ describe('AI Builder store', () => {
});
// Retry the failed request
await errorMessage.retry?.();
// Should now have just the user message and success message
expect(builderStore.chatMessages.length).toBe(2);
if (errorMessage.retry) {
void errorMessage.retry();
await vi.waitFor(() => 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(

View File

@@ -1,4 +1,3 @@
import { chatWithBuilder } from '@/api/ai';
import type { VIEWS } from '@/constants';
import {
ASK_AI_SLIDE_OUT_DURATION_MS,
@@ -6,12 +5,10 @@ import {
WORKFLOW_BUILDER_EXPERIMENT,
} from '@/constants';
import { STORES } from '@n8n/stores';
import type { ChatRequest } from '@/types/assistant.types';
import type { ChatUI } from '@n8n/design-system/types/assistant';
import { isToolMessage, isWorkflowUpdatedMessage } from '@n8n/design-system/types/assistant';
import { defineStore } from 'pinia';
import { computed, ref, watch } from 'vue';
import { useRootStore } from '@n8n/stores/useRootStore';
import { useUsersStore } from './users.store';
import { computed, ref } from 'vue';
import { useRoute } from 'vue-router';
import { useSettingsStore } from './settings.store';
import { assert } from '@n8n/utils/assert';
@@ -19,8 +16,16 @@ import { useI18n } from '@n8n/i18n';
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';
import { useWorkflowsStore } from './workflows.store';
import { useBuilderMessages } from '@/composables/useBuilderMessages';
import { chatWithBuilder, getAiSessions } from '@/api/ai';
import { generateMessageId, createBuilderPayload } from '@/helpers/builderHelpers';
import { useRootStore } from '@n8n/stores/useRootStore';
import type { WorkflowDataUpdate } from '@n8n/rest-api-client/api/workflows';
import pick from 'lodash/pick';
import { jsonParse } from 'n8n-workflow';
import { useToast } from '@/composables/useToast';
export const ENABLED_VIEWS = [...EDITABLE_CANVAS_VIEWS];
@@ -30,19 +35,26 @@ export const useBuilderStore = defineStore(STORES.BUILDER, () => {
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 workflowsStore = useWorkflowsStore();
const uiStore = useUIStore();
const route = useRoute();
const locale = useI18n();
const telemetry = useTelemetry();
const posthogStore = usePostHog();
const nodeTypesStore = useNodeTypesStore();
// Composables
const {
processAssistantMessages,
createUserMessage,
createErrorMessage,
clearMessages,
mapAssistantMessageToUI,
} = useBuilderMessages();
// Computed properties
const isAssistantEnabled = computed(() => settings.isAiAssistantEnabled);
@@ -71,25 +83,40 @@ export const useBuilderStore = defineStore(STORES.BUILDER, () => {
);
});
// No need to track unread messages in the AI Builder
const unreadCount = computed(() => 0);
const toolMessages = computed(() => chatMessages.value.filter(isToolMessage));
const workflowMessages = computed(() => chatMessages.value.filter(isWorkflowUpdatedMessage));
// Chat management functions
/**
* Resets the entire chat session to initial state.
* Called when user navigates away from workflow or explicitly requests a new workflow.
* Note: Does not persist the cleared state - sessions can still be reloaded via loadSessions().
*/
function resetBuilderChat() {
clearMessages();
currentSessionId.value = undefined;
chatMessages.value = clearMessages();
assistantThinkingMessage.value = undefined;
}
function openChat() {
/**
* Opens the chat panel and adjusts the canvas viewport to make room.
*/
async function openChat() {
chatWindowOpen.value = true;
chatMessages.value = chatMessages.value.map((msg) => ({ ...msg, read: true }));
chatMessages.value = [];
uiStore.appGridDimensions = {
...uiStore.appGridDimensions,
width: window.innerWidth - chatWidth.value,
};
await loadSessions();
}
/**
* Closes the chat panel with a delayed viewport restoration.
* The delay (ASK_AI_SLIDE_OUT_DURATION_MS + 50ms) ensures the slide-out animation
* completes before expanding the canvas, preventing visual jarring.
* Messages remain in memory.
*/
function closeChat() {
chatWindowOpen.value = false;
// Looks smoother if we wait for slide animation to finish before updating the grid width
@@ -106,236 +133,244 @@ export const useBuilderStore = defineStore(STORES.BUILDER, () => {
}, ASK_AI_SLIDE_OUT_DURATION_MS + 50);
}
function clearMessages() {
chatMessages.value = [];
}
/**
* Updates chat panel width with enforced boundaries.
* Width is clamped between MIN_CHAT_WIDTH (330px) and MAX_CHAT_WIDTH (650px)
* to ensure usability on various screen sizes.
*/
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
/**
* Handles streaming errors by creating an error message with optional retry capability.
* Cleans up streaming state and removes the thinking indicator.
* The retry function, if provided, will remove the error message before retrying.
* Tracks error telemetry
*/
function handleServiceError(e: unknown, id: string, retry?: () => Promise<void>) {
assert(e instanceof Error);
stopStreaming();
assistantThinkingMessage.value = undefined;
addAssistantError(
const errorMessage = createErrorMessage(
locale.baseText('aiAssistant.serviceError.message', { interpolate: { message: e.message } }),
id,
retry,
);
chatMessages.value = [...chatMessages.value, errorMessage];
telemetry.track('Workflow generation errored', {
error: e.message,
prompt: workflowPrompt.value,
workflow_id: workflowsStore.workflowId,
});
}
// API interaction
function getRandomId() {
return `${Math.floor(Math.random() * 100000000)}`;
// Helper functions
/**
* Prepares UI for incoming streaming response.
* Adds user message immediately for visual feedback, shows thinking indicator,
* and ensures chat is open. Called before initiating API request to minimize
* perceived latency.
*/
function prepareForStreaming(userMessage: string, messageId: string) {
const userMsg = createUserMessage(userMessage, messageId);
chatMessages.value = [...chatMessages.value, userMsg];
addLoadingAssistantMessage(locale.baseText('aiAssistant.thinkingSteps.thinking'));
streaming.value = true;
}
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',
});
} else if (currentSessionId.value !== response.sessionId) {
// Ignore messages from other sessions
return;
}
addAssistantMessages(response.messages, id);
}
function onDoneStreaming() {
stopStreaming();
/**
* Creates a retry function that removes the associated error message before retrying.
* This ensures the chat doesn't accumulate multiple error messages for the same failure.
* The messageId parameter refers to the error message to remove, not the original user message.
*/
function createRetryHandler(messageId: string, retryFn: () => Promise<void>) {
return async () => {
// Remove the error message before retrying
chatMessages.value = chatMessages.value.filter((msg) => msg.id !== messageId);
await retryFn();
};
}
// Core API functions
async function initBuilderChat(userMessage: string, source: 'chat' | 'canvas') {
telemetry.track('User submitted workflow prompt', {
source,
prompt: userMessage,
});
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'>,
) {
/**
* Sends a message to the AI builder service and handles the streaming response.
* Prevents concurrent requests by checking streaming state.
* Captures workflow state before sending for comparison in telemetry.
* Creates a retry handler that preserves the original message context.
* Note: This function is NOT async - streaming happens via callbacks.
*/
function sendChatMessage(options: {
text: string;
source?: 'chat' | 'canvas';
quickReplyType?: string;
}) {
if (streaming.value) {
return;
}
const id = getRandomId();
const { text, source = 'chat', quickReplyType } = options;
const messageId = generateMessageId();
const retry = async () => {
chatMessages.value = chatMessages.value.filter((msg) => msg.id !== id);
await sendMessage(chatMessage);
};
const currentWorkflowJson = getWorkflowSnapshot();
telemetry.track('User submitted builder message', {
source,
message: text,
start_workflow_json: currentWorkflowJson,
workflow_id: workflowsStore.workflowId,
});
prepareForStreaming(text, messageId);
const executionResult = workflowsStore.workflowExecutionData?.data?.resultData;
const payload = createBuilderPayload(text, {
quickReplyType,
workflow: workflowsStore.workflow,
executionData: executionResult,
nodesForSchema: Object.keys(workflowsStore.nodesByName),
});
const retry = createRetryHandler(messageId, async () => sendChatMessage(options));
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,
{ payload },
(response) => {
const result = processAssistantMessages(
chatMessages.value,
response.messages,
generateMessageId(),
);
chatMessages.value = result.messages;
if (result.shouldClearThinking) {
assistantThinkingMessage.value = undefined;
}
if (result.thinkingMessage) {
assistantThinkingMessage.value = result.thinkingMessage;
}
},
(msg) => onEachStreamingMessage(msg, id),
() => onDoneStreaming(),
(e) => handleServiceError(e, id, retry),
() => stopStreaming(),
(e) => handleServiceError(e, messageId, retry),
);
} catch (e: unknown) {
// in case of assert
handleServiceError(e, id, retry);
handleServiceError(e, messageId, retry);
}
}
// Reset on route change
watch(route, () => {
resetBuilderChat();
});
/**
* Loads the most recent chat session for the current workflow.
* Only loads if a workflow ID exists (not for new unsaved workflows).
* Replaces current chat messages entirely - does NOT merge with existing messages.
* Sessions are ordered by recency, so sessions[0] is always the latest.
* Silently fails and returns empty array on error to prevent UI disruption.
*/
async function loadSessions() {
const workflowId = workflowsStore.workflowId;
if (!workflowId) {
return [];
}
try {
const response = await getAiSessions(rootStore.restApiContext, workflowId);
const sessions = response.sessions || [];
// Load the most recent session if available
if (sessions.length > 0) {
const latestSession = sessions[0];
// Clear existing messages
chatMessages.value = clearMessages();
// Convert and add messages from the session
const convertedMessages = latestSession.messages
.map((msg) => {
const id = generateMessageId();
return mapAssistantMessageToUI(msg, id);
})
// Do not include wf updated messages from session
.filter((msg) => msg.type !== 'workflow-updated');
chatMessages.value = convertedMessages;
}
return sessions;
} catch (error) {
console.error('Failed to load AI sessions:', error);
return [];
}
}
function captureCurrentWorkflowState() {
const nodePositions = new Map<string, [number, number]>();
const existingNodeIds = new Set<string>();
workflowsStore.allNodes.forEach((node) => {
nodePositions.set(node.id, [...node.position]);
existingNodeIds.add(node.id);
});
return {
nodePositions,
existingNodeIds,
currentWorkflowJson: JSON.stringify(pick(workflowsStore.workflow, ['nodes', 'connections'])),
};
}
function applyWorkflowUpdate(workflowJson: string) {
let workflowData: WorkflowDataUpdate;
try {
workflowData = jsonParse<WorkflowDataUpdate>(workflowJson);
} catch (error) {
useToast().showMessage({
type: 'error',
title: locale.baseText('aiAssistant.builder.workflowParsingError.title'),
message: locale.baseText('aiAssistant.builder.workflowParsingError.content'),
});
return { success: false, error };
}
// Capture current state before clearing
const { nodePositions } = captureCurrentWorkflowState();
// Clear existing workflow
workflowsStore.removeAllConnections({ setStateDirty: false });
workflowsStore.removeAllNodes({ setStateDirty: false, removePinData: true });
// Restore positions for nodes that still exist and identify new nodes
const nodesIdsToTidyUp: string[] = [];
if (workflowData.nodes) {
workflowData.nodes = workflowData.nodes.map((node) => {
const savedPosition = nodePositions.get(node.id);
if (savedPosition) {
return { ...node, position: savedPosition };
} else {
// This is a new node, add it to the tidy up list
nodesIdsToTidyUp.push(node.id);
}
return node;
});
}
return { success: true, workflowData, newNodeIds: nodesIdsToTidyUp };
}
function getWorkflowSnapshot() {
return JSON.stringify(pick(workflowsStore.workflow, ['nodes', 'connections']));
}
// Public API
return {
@@ -344,24 +379,24 @@ export const useBuilderStore = defineStore(STORES.BUILDER, () => {
canShowAssistantButtonsOnCanvas,
chatWidth,
chatMessages,
unreadCount,
streaming,
isAssistantOpen,
canShowAssistant,
currentSessionId,
assistantThinkingMessage,
chatWindowOpen,
isAIBuilderEnabled,
workflowPrompt,
toolMessages,
workflowMessages,
// Methods
updateWindowWidth,
closeChat,
openChat,
resetBuilderChat,
initBuilderChat,
sendMessage,
addAssistantMessages,
handleServiceError,
sendChatMessage,
loadSessions,
applyWorkflowUpdate,
getWorkflowSnapshot,
};
});

View File

@@ -10,6 +10,7 @@ import type {
IRunExecutionData,
ITaskData,
} from 'n8n-workflow';
import type { ChatUI } from '@n8n/design-system/types/assistant';
export namespace ChatRequest {
export interface NodeExecutionSchema {
@@ -63,17 +64,6 @@ 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';
@@ -127,118 +117,70 @@ export namespace ChatRequest {
export type RequestPayload =
| {
payload: InitErrorHelper | InitSupportChat | InitCredHelp | InitBuilderChat;
payload: InitErrorHelper | InitSupportChat | InitCredHelp;
}
| {
payload: EventRequestPayload | UserChatMessage;
sessionId: string;
sessionId?: string;
};
interface CodeDiffMessage {
role: 'assistant';
type: 'code-diff';
description?: string;
codeDiff?: string;
suggestionId: string;
solution_count: number;
// Re-export types from design-system for backward compatibility
export type ToolMessage = ChatUI.ToolMessage;
// API-specific types that extend UI types
export interface CodeDiffMessage extends ChatUI.CodeDiffMessage {
solution_count?: number;
quickReplies?: ChatUI.QuickReply[];
}
interface QuickReplyOption {
text: string;
type: string;
isFeedback?: boolean;
}
interface AssistantChatMessage {
role: 'assistant';
type: 'message';
text: string;
step?: 'n8n_documentation' | 'n8n_forum';
codeSnippet?: string;
}
interface AssistantSummaryMessage {
role: 'assistant';
type: 'summary';
title: string;
content: string;
}
interface EndSessionMessage {
role: 'assistant';
type: 'event';
eventName: 'end-session';
}
interface AgentChatMessage {
role: 'assistant';
type: 'agent-suggestion';
title: string;
text: string;
}
interface AgentThinkingStep {
export interface AgentThinkingStep {
role: 'assistant';
type: 'intermediate-step';
text: string;
step: string;
}
interface WorkflowStepMessage {
role: 'assistant';
type: 'workflow-step';
steps: string[];
// API-specific types that extend UI types
export interface TextMessage {
role: 'assistant' | 'user';
type: 'message'; // API uses 'message' instead of 'text'
text: string;
step?: 'n8n_documentation' | 'n8n_forum';
codeSnippet?: string;
quickReplies?: ChatUI.QuickReply[];
}
interface WorkflowNodeMessage {
export interface SummaryMessage {
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';
type: 'summary'; // API uses 'summary' instead of 'block'
title: string;
content: string;
}
export interface AgentSuggestionMessage {
role: 'assistant';
type: 'agent-suggestion';
title: string;
text: string; // API uses text instead of content
suggestionId?: string;
}
// API-only types
export type MessageResponse =
| ((
| AssistantChatMessage
| TextMessage
| CodeDiffMessage
| AssistantSummaryMessage
| AgentChatMessage
| SummaryMessage
| AgentSuggestionMessage
| AgentThinkingStep
| WorkflowStepMessage
| WorkflowNodeMessage
| WorkflowComposedMessage
| WorkflowPromptValidationMessage
| WorkflowGeneratedMessage
| RateWorkflowMessage
| ChatUI.WorkflowUpdatedMessage
| ToolMessage
| ChatUI.ErrorMessage
) & {
quickReplies?: QuickReplyOption[];
quickReplies?: ChatUI.QuickReply[];
})
| EndSessionMessage;
| ChatUI.EndSessionMessage;
export interface ResponsePayload {
sessionId?: string;
@@ -279,3 +221,48 @@ export namespace AskAiRequest {
forNode: 'code' | 'transform';
}
}
// Type guards for ChatRequest messages
export function isTextMessage(msg: ChatRequest.MessageResponse): msg is ChatRequest.TextMessage {
return 'type' in msg && msg.type === 'message' && 'text' in msg;
}
export function isSummaryMessage(
msg: ChatRequest.MessageResponse,
): msg is ChatRequest.SummaryMessage {
return 'type' in msg && msg.type === 'summary' && 'title' in msg && 'content' in msg;
}
export function isAgentSuggestionMessage(
msg: ChatRequest.MessageResponse,
): msg is ChatRequest.AgentSuggestionMessage {
return 'type' in msg && msg.type === 'agent-suggestion' && 'title' in msg && 'text' in msg;
}
export function isAgentThinkingMessage(
msg: ChatRequest.MessageResponse,
): msg is ChatRequest.AgentThinkingStep {
return 'type' in msg && msg.type === 'intermediate-step' && 'step' in msg;
}
export function isCodeDiffMessage(
msg: ChatRequest.MessageResponse,
): msg is ChatRequest.CodeDiffMessage {
return 'type' in msg && msg.type === 'code-diff' && 'codeDiff' in msg;
}
export function isWorkflowUpdatedMessage(
msg: ChatRequest.MessageResponse,
): msg is ChatUI.WorkflowUpdatedMessage {
return 'type' in msg && msg.type === 'workflow-updated' && 'codeSnippet' in msg;
}
export function isToolMessage(msg: ChatRequest.MessageResponse): msg is ChatRequest.ToolMessage {
return 'type' in msg && msg.type === 'tool' && 'toolName' in msg && 'status' in msg;
}
export function isEndSessionMessage(
msg: ChatRequest.MessageResponse,
): msg is ChatUI.EndSessionMessage {
return 'type' in msg && msg.type === 'event' && msg.eventName === 'end-session';
}

View File

@@ -181,7 +181,7 @@ export type CanvasEventBusEvents = {
action: keyof CanvasNodeEventBusEvents;
payload?: CanvasNodeEventBusEvents[keyof CanvasNodeEventBusEvents];
};
tidyUp: { source: CanvasLayoutSource };
tidyUp: { source: CanvasLayoutSource; nodeIdsFilter?: string[] };
};
export interface CanvasNodeInjectionData {

View File

@@ -1083,13 +1083,18 @@ async function onImportWorkflowDataEvent(data: IDataObject) {
const workflowData = data.data as WorkflowDataUpdate;
await importWorkflowData(workflowData, 'file', {
viewport: viewportBoundaries.value,
regenerateIds: data.regenerateIds === true || data.regenerateIds === undefined,
});
fitView();
selectNodes(workflowData.nodes?.map((node) => node.id) ?? []);
if (data.tidyUp) {
const nodesIdsToTidyUp = data.nodesIdsToTidyUp as string[];
setTimeout(() => {
canvasEventBus.emit('tidyUp', { source: 'import-workflow-data' });
canvasEventBus.emit('tidyUp', {
source: 'import-workflow-data',
nodeIdsFilter: nodesIdsToTidyUp,
});
}, 0);
}
}