mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 01:56:46 +00:00
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:
@@ -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,
|
||||
},
|
||||
]),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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'"
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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',
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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('');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
56
packages/frontend/editor-ui/src/helpers/builderHelpers.ts
Normal file
56
packages/frontend/editor-ui/src/helpers/builderHelpers.ts
Normal 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);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user