mirror of
https://github.com/n8n-io/n8n.git
synced 2024-11-09 22:24:05 -08:00
Merge branch 'master' of https://github.com/n8n-io/n8n into node-1472-add-form-node-p0
This commit is contained in:
commit
2a2f4c496c
|
@ -130,14 +130,15 @@ describe('AI Assistant::enabled', () => {
|
|||
ndv.getters.nodeExecuteButton().click();
|
||||
aiAssistant.getters.nodeErrorViewAssistantButton().click();
|
||||
cy.wait('@chatRequest');
|
||||
aiAssistant.getters.quickReplies().should('have.length', 2);
|
||||
aiAssistant.getters.quickReplies().eq(0).click();
|
||||
aiAssistant.getters.quickReplyButtons().should('have.length', 2);
|
||||
aiAssistant.getters.quickReplyButtons().eq(0).click();
|
||||
cy.wait('@chatRequest');
|
||||
aiAssistant.getters.chatMessagesUser().should('have.length', 1);
|
||||
aiAssistant.getters.chatMessagesUser().eq(0).should('contain.text', "Sure, let's do it");
|
||||
});
|
||||
|
||||
it('should send message to assistant when node is executed', () => {
|
||||
it('should send message to assistant when node is executed only once', () => {
|
||||
const TOTAL_REQUEST_COUNT = 1;
|
||||
cy.intercept('POST', '/rest/ai-assistant/chat', {
|
||||
statusCode: 200,
|
||||
fixture: 'aiAssistant/simple_message_response.json',
|
||||
|
@ -148,10 +149,46 @@ describe('AI Assistant::enabled', () => {
|
|||
aiAssistant.getters.nodeErrorViewAssistantButton().click();
|
||||
cy.wait('@chatRequest');
|
||||
aiAssistant.getters.chatMessagesAssistant().should('have.length', 1);
|
||||
// Executing the same node should sende a new message to the assistant automatically
|
||||
cy.get('@chatRequest.all').then((interceptions) => {
|
||||
expect(interceptions).to.have.length(TOTAL_REQUEST_COUNT);
|
||||
});
|
||||
// Executing the same node should not send a new message if users haven't responded to quick replies
|
||||
ndv.getters.nodeExecuteButton().click();
|
||||
cy.get('@chatRequest.all').then((interceptions) => {
|
||||
expect(interceptions).to.have.length(TOTAL_REQUEST_COUNT);
|
||||
});
|
||||
aiAssistant.getters.chatMessagesAssistant().should('have.length', 2);
|
||||
});
|
||||
|
||||
it('should show quick replies when node is executed after new suggestion', () => {
|
||||
cy.intercept('POST', '/rest/ai-assistant/chat', (req) => {
|
||||
req.reply((res) => {
|
||||
if (['init-error-helper', 'message'].includes(req.body.payload.type)) {
|
||||
res.send({ statusCode: 200, fixture: 'aiAssistant/simple_message_response.json' });
|
||||
} else if (req.body.payload.type === 'event') {
|
||||
res.send({ statusCode: 200, fixture: 'aiAssistant/node_execution_error_response.json' });
|
||||
} else {
|
||||
res.send({ statusCode: 500 });
|
||||
}
|
||||
});
|
||||
}).as('chatRequest');
|
||||
cy.createFixtureWorkflow('aiAssistant/test_workflow.json');
|
||||
wf.actions.openNode('Edit Fields');
|
||||
ndv.getters.nodeExecuteButton().click();
|
||||
aiAssistant.getters.nodeErrorViewAssistantButton().click();
|
||||
cy.wait('@chatRequest');
|
||||
aiAssistant.getters.chatMessagesAssistant().should('have.length', 1);
|
||||
ndv.getters.nodeExecuteButton().click();
|
||||
cy.wait('@chatRequest');
|
||||
aiAssistant.getters.chatMessagesAssistant().should('have.length', 2);
|
||||
// Respond 'Yes' to the quick reply (request new suggestion)
|
||||
aiAssistant.getters.quickReplies().contains('Yes').click();
|
||||
cy.wait('@chatRequest');
|
||||
// No quick replies at this point
|
||||
aiAssistant.getters.quickReplies().should('not.exist');
|
||||
ndv.getters.nodeExecuteButton().click();
|
||||
// But after executing the node again, quick replies should be shown
|
||||
aiAssistant.getters.chatMessagesAssistant().should('have.length', 4);
|
||||
aiAssistant.getters.quickReplies().should('have.length', 2);
|
||||
});
|
||||
|
||||
it('should warn before starting a new session', () => {
|
||||
|
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"sessionId": "1",
|
||||
"messages": [
|
||||
{
|
||||
"role": "assistant",
|
||||
"type": "message",
|
||||
"text": "It seems like my suggestion did not work. Do you want me to come up with a different suggestion? You can also provide more context via the chat.",
|
||||
"quickReplies": [
|
||||
{
|
||||
"text": "Yes",
|
||||
"type": "new-suggestion"
|
||||
},
|
||||
{
|
||||
"text": "No, I don't think you can help",
|
||||
"type": "event:end-session"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
|
@ -26,7 +26,8 @@ export class AIAssistant extends BasePage {
|
|||
chatMessagesAssistant: () => cy.getByTestId('chat-message-assistant'),
|
||||
chatMessagesUser: () => cy.getByTestId('chat-message-user'),
|
||||
chatMessagesSystem: () => cy.getByTestId('chat-message-system'),
|
||||
quickReplies: () => cy.getByTestId('quick-replies').find('button'),
|
||||
quickReplies: () => cy.getByTestId('quick-replies'),
|
||||
quickReplyButtons: () => this.getters.quickReplies().find('button'),
|
||||
newAssistantSessionModal: () => cy.getByTestId('new-assistant-session-modal'),
|
||||
codeDiffs: () => cy.getByTestId('code-diff-suggestion'),
|
||||
applyCodeDiffButtons: () => cy.getByTestId('replace-code-button'),
|
||||
|
|
|
@ -0,0 +1,276 @@
|
|||
import { type INodeProperties } from 'n8n-workflow';
|
||||
import {
|
||||
PGVectorStore,
|
||||
type DistanceStrategy,
|
||||
type PGVectorStoreArgs,
|
||||
} from '@langchain/community/vectorstores/pgvector';
|
||||
import { configurePostgres } from 'n8n-nodes-base/dist/nodes/Postgres/v2/transport';
|
||||
import type { PostgresNodeCredentials } from 'n8n-nodes-base/dist/nodes/Postgres/v2/helpers/interfaces';
|
||||
import type pg from 'pg';
|
||||
import { createVectorStoreNode } from '../shared/createVectorStoreNode';
|
||||
import { metadataFilterField } from '../../../utils/sharedFields';
|
||||
|
||||
type CollectionOptions = {
|
||||
useCollection?: boolean;
|
||||
collectionName?: string;
|
||||
collectionTableName?: string;
|
||||
};
|
||||
|
||||
type ColumnOptions = {
|
||||
idColumnName: string;
|
||||
vectorColumnName: string;
|
||||
contentColumnName: string;
|
||||
metadataColumnName: string;
|
||||
};
|
||||
|
||||
const sharedFields: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Table Name',
|
||||
name: 'tableName',
|
||||
type: 'string',
|
||||
default: 'n8n_vectors',
|
||||
description:
|
||||
'The table name to store the vectors in. If table does not exist, it will be created.',
|
||||
},
|
||||
];
|
||||
|
||||
const collectionField: INodeProperties = {
|
||||
displayName: 'Collection',
|
||||
name: 'collection',
|
||||
type: 'fixedCollection',
|
||||
description: 'Collection of vectors',
|
||||
default: {
|
||||
values: {
|
||||
useCollection: false,
|
||||
collectionName: 'n8n',
|
||||
collectionTable: 'n8n_vector_collections',
|
||||
},
|
||||
},
|
||||
typeOptions: {},
|
||||
placeholder: 'Add Collection Settings',
|
||||
options: [
|
||||
{
|
||||
name: 'values',
|
||||
displayName: 'Collection Settings',
|
||||
values: [
|
||||
{
|
||||
displayName: 'Use Collection',
|
||||
name: 'useCollection',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
},
|
||||
{
|
||||
displayName: 'Collection Name',
|
||||
name: 'collectionName',
|
||||
type: 'string',
|
||||
default: 'n8n',
|
||||
required: true,
|
||||
displayOptions: { show: { useCollection: [true] } },
|
||||
},
|
||||
{
|
||||
displayName: 'Collection Table Name',
|
||||
name: 'collectionTableName',
|
||||
type: 'string',
|
||||
default: 'n8n_vector_collections',
|
||||
required: true,
|
||||
displayOptions: { show: { useCollection: [true] } },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const columnNamesField: INodeProperties = {
|
||||
displayName: 'Column Names',
|
||||
name: 'columnNames',
|
||||
type: 'fixedCollection',
|
||||
description: 'The names of the columns in the PGVector table',
|
||||
default: {
|
||||
values: {
|
||||
idColumnName: 'id',
|
||||
vectorColumnName: 'embedding',
|
||||
contentColumnName: 'text',
|
||||
metadataColumnName: 'metadata',
|
||||
},
|
||||
},
|
||||
typeOptions: {},
|
||||
placeholder: 'Set Column Names',
|
||||
options: [
|
||||
{
|
||||
name: 'values',
|
||||
displayName: 'Column Name Settings',
|
||||
values: [
|
||||
{
|
||||
displayName: 'ID Column Name',
|
||||
name: 'idColumnName',
|
||||
type: 'string',
|
||||
default: 'id',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
displayName: 'Vector Column Name',
|
||||
name: 'vectorColumnName',
|
||||
type: 'string',
|
||||
default: 'embedding',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
displayName: 'Content Column Name',
|
||||
name: 'contentColumnName',
|
||||
type: 'string',
|
||||
default: 'text',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
displayName: 'Metadata Column Name',
|
||||
name: 'metadataColumnName',
|
||||
type: 'string',
|
||||
default: 'metadata',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const distanceStrategyField: INodeProperties = {
|
||||
displayName: 'Distance Strategy',
|
||||
name: 'distanceStrategy',
|
||||
type: 'options',
|
||||
default: 'cosine',
|
||||
description: 'The method to calculate the distance between two vectors',
|
||||
options: [
|
||||
{
|
||||
name: 'Cosine',
|
||||
value: 'cosine',
|
||||
},
|
||||
{
|
||||
name: 'Inner Product',
|
||||
value: 'innerProduct',
|
||||
},
|
||||
{
|
||||
name: 'Euclidean',
|
||||
value: 'euclidean',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const insertFields: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Options',
|
||||
name: 'options',
|
||||
type: 'collection',
|
||||
placeholder: 'Add Option',
|
||||
default: {},
|
||||
options: [collectionField, columnNamesField],
|
||||
},
|
||||
];
|
||||
|
||||
const retrieveFields: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Options',
|
||||
name: 'options',
|
||||
type: 'collection',
|
||||
placeholder: 'Add Option',
|
||||
default: {},
|
||||
options: [distanceStrategyField, collectionField, columnNamesField, metadataFilterField],
|
||||
},
|
||||
];
|
||||
|
||||
export const VectorStorePGVector = createVectorStoreNode({
|
||||
meta: {
|
||||
description: 'Work with your data in Postgresql with the PGVector extension',
|
||||
icon: 'file:postgres.svg',
|
||||
displayName: 'Postgres PGVector Store',
|
||||
docsUrl:
|
||||
'https://docs.n8n.io/integrations/builtin/cluster-nodes/root-nodes/n8n-nodes-langchain.vectorstoresupabase/',
|
||||
name: 'vectorStorePGVector',
|
||||
credentials: [
|
||||
{
|
||||
name: 'postgres',
|
||||
required: true,
|
||||
testedBy: 'postgresConnectionTest',
|
||||
},
|
||||
],
|
||||
operationModes: ['load', 'insert', 'retrieve'],
|
||||
},
|
||||
sharedFields,
|
||||
insertFields,
|
||||
loadFields: retrieveFields,
|
||||
retrieveFields,
|
||||
async getVectorStoreClient(context, filter, embeddings, itemIndex) {
|
||||
const tableName = context.getNodeParameter('tableName', itemIndex, '', {
|
||||
extractValue: true,
|
||||
}) as string;
|
||||
const credentials = await context.getCredentials('postgres');
|
||||
const pgConf = await configurePostgres.call(context, credentials as PostgresNodeCredentials);
|
||||
const pool = pgConf.db.$pool as unknown as pg.Pool;
|
||||
|
||||
const config: PGVectorStoreArgs = {
|
||||
pool,
|
||||
tableName,
|
||||
filter,
|
||||
};
|
||||
|
||||
const collectionOptions = context.getNodeParameter(
|
||||
'options.collection.values',
|
||||
0,
|
||||
{},
|
||||
) as CollectionOptions;
|
||||
|
||||
if (collectionOptions && collectionOptions.useCollection) {
|
||||
config.collectionName = collectionOptions.collectionName;
|
||||
config.collectionTableName = collectionOptions.collectionTableName;
|
||||
}
|
||||
|
||||
config.columns = context.getNodeParameter('options.columnNames.values', 0, {
|
||||
idColumnName: 'id',
|
||||
vectorColumnName: 'embedding',
|
||||
contentColumnName: 'text',
|
||||
metadataColumnName: 'metadata',
|
||||
}) as ColumnOptions;
|
||||
|
||||
config.distanceStrategy = context.getNodeParameter(
|
||||
'options.distanceStrategy',
|
||||
0,
|
||||
'cosine',
|
||||
) as DistanceStrategy;
|
||||
|
||||
return await PGVectorStore.initialize(embeddings, config);
|
||||
},
|
||||
async populateVectorStore(context, embeddings, documents, itemIndex) {
|
||||
// NOTE: if you are to create the HNSW index before use, you need to consider moving the distanceStrategy field to
|
||||
// shared fields, because you need that strategy when creating the index.
|
||||
const tableName = context.getNodeParameter('tableName', itemIndex, '', {
|
||||
extractValue: true,
|
||||
}) as string;
|
||||
const credentials = await context.getCredentials('postgres');
|
||||
const pgConf = await configurePostgres.call(context, credentials as PostgresNodeCredentials);
|
||||
const pool = pgConf.db.$pool as unknown as pg.Pool;
|
||||
|
||||
const config: PGVectorStoreArgs = {
|
||||
pool,
|
||||
tableName,
|
||||
};
|
||||
|
||||
const collectionOptions = context.getNodeParameter(
|
||||
'options.collection.values',
|
||||
0,
|
||||
{},
|
||||
) as CollectionOptions;
|
||||
|
||||
if (collectionOptions && collectionOptions.useCollection) {
|
||||
config.collectionName = collectionOptions.collectionName;
|
||||
config.collectionTableName = collectionOptions.collectionTableName;
|
||||
}
|
||||
|
||||
config.columns = context.getNodeParameter('options.columnNames.values', 0, {
|
||||
idColumnName: 'id',
|
||||
vectorColumnName: 'embedding',
|
||||
contentColumnName: 'text',
|
||||
metadataColumnName: 'metadata',
|
||||
}) as ColumnOptions;
|
||||
|
||||
await PGVectorStore.fromDocuments(documents, embeddings, config);
|
||||
},
|
||||
});
|
File diff suppressed because one or more lines are too long
After Width: | Height: | Size: 5.9 KiB |
|
@ -108,6 +108,7 @@
|
|||
"dist/nodes/vector_store/VectorStoreInMemory/VectorStoreInMemory.node.js",
|
||||
"dist/nodes/vector_store/VectorStoreInMemoryInsert/VectorStoreInMemoryInsert.node.js",
|
||||
"dist/nodes/vector_store/VectorStoreInMemoryLoad/VectorStoreInMemoryLoad.node.js",
|
||||
"dist/nodes/vector_store/VectorStorePGVector/VectorStorePGVector.node.js",
|
||||
"dist/nodes/vector_store/VectorStorePinecone/VectorStorePinecone.node.js",
|
||||
"dist/nodes/vector_store/VectorStorePineconeInsert/VectorStorePineconeInsert.node.js",
|
||||
"dist/nodes/vector_store/VectorStorePineconeLoad/VectorStorePineconeLoad.node.js",
|
||||
|
|
|
@ -238,3 +238,12 @@ EndOfSessionChat.args = {
|
|||
},
|
||||
]),
|
||||
};
|
||||
|
||||
export const AssistantThinkingChat = Template.bind({});
|
||||
AssistantThinkingChat.args = {
|
||||
user: {
|
||||
firstName: 'Max',
|
||||
lastName: 'Test',
|
||||
},
|
||||
loadingMessage: 'Thinking...',
|
||||
};
|
||||
|
|
|
@ -3,6 +3,7 @@ import { computed, ref } from 'vue';
|
|||
import AssistantIcon from '../AskAssistantIcon/AssistantIcon.vue';
|
||||
import AssistantText from '../AskAssistantText/AssistantText.vue';
|
||||
import AssistantAvatar from '../AskAssistantAvatar/AssistantAvatar.vue';
|
||||
import AssistantLoadingMessage from '../AskAssistantLoadingMessage/AssistantLoadingMessage.vue';
|
||||
import CodeDiff from '../CodeDiff/CodeDiff.vue';
|
||||
import type { ChatUI } from '../../types/assistant';
|
||||
import BlinkingCursor from '../BlinkingCursor/BlinkingCursor.vue';
|
||||
|
@ -33,11 +34,13 @@ interface Props {
|
|||
};
|
||||
messages?: ChatUI.AssistantMessage[];
|
||||
streaming?: boolean;
|
||||
loadingMessage?: string;
|
||||
sessionId?: string;
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: [];
|
||||
message: [string, string | undefined];
|
||||
message: [string, string?, boolean?];
|
||||
codeReplace: [number];
|
||||
codeUndo: [number];
|
||||
}>();
|
||||
|
@ -58,17 +61,21 @@ const sendDisabled = computed(() => {
|
|||
return !textInputValue.value || props.streaming || sessionEnded.value;
|
||||
});
|
||||
|
||||
const showPlaceholder = computed(() => {
|
||||
return !props.messages?.length && !props.loadingMessage && !props.sessionId;
|
||||
});
|
||||
|
||||
function isEndOfSessionEvent(event?: ChatUI.AssistantMessage) {
|
||||
return event?.type === 'event' && event?.eventName === 'end-session';
|
||||
}
|
||||
|
||||
function onQuickReply(opt: ChatUI.QuickReply) {
|
||||
emit('message', opt.text, opt.type);
|
||||
emit('message', opt.text, opt.type, opt.isFeedback);
|
||||
}
|
||||
|
||||
function onSendMessage() {
|
||||
if (sendDisabled.value) return;
|
||||
emit('message', textInputValue.value, undefined);
|
||||
emit('message', textInputValue.value);
|
||||
textInputValue.value = '';
|
||||
if (chatInput.value) {
|
||||
chatInput.value.style.height = 'auto';
|
||||
|
@ -221,26 +228,30 @@ function growInput() {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else :class="$style.placeholder" data-test-id="placeholder-message">
|
||||
<div v-if="loadingMessage" :class="$style.messages">
|
||||
<AssistantLoadingMessage :message="loadingMessage" />
|
||||
</div>
|
||||
<div
|
||||
v-else-if="showPlaceholder"
|
||||
:class="$style.placeholder"
|
||||
data-test-id="placeholder-message"
|
||||
>
|
||||
<div :class="$style.greeting">Hi {{ user?.firstName }} 👋</div>
|
||||
<div :class="$style.info">
|
||||
<p>
|
||||
{{
|
||||
t('assistantChat.placeholder.1', [
|
||||
`${user?.firstName}`,
|
||||
t('assistantChat.aiAssistantName'),
|
||||
])
|
||||
}}
|
||||
{{ t('assistantChat.placeholder.1') }}
|
||||
</p>
|
||||
<p>
|
||||
{{ t('assistantChat.placeholder.2') }}
|
||||
<InlineAskAssistantButton size="small" :static="true" />
|
||||
{{ t('assistantChat.placeholder.3') }}
|
||||
</p>
|
||||
<p>
|
||||
{{ t('assistantChat.placeholder.3') }}
|
||||
<InlineAskAssistantButton size="small" :static="true" />
|
||||
{{ t('assistantChat.placeholder.4') }}
|
||||
</p>
|
||||
<p>
|
||||
{{ t('assistantChat.placeholder.5') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -325,6 +336,10 @@ p {
|
|||
|
||||
.messages {
|
||||
padding: var(--spacing-xs);
|
||||
|
||||
& + & {
|
||||
padding-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.message {
|
||||
|
@ -391,6 +406,8 @@ p {
|
|||
}
|
||||
|
||||
.textMessage {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: var(--font-size-2xs);
|
||||
}
|
||||
|
||||
|
|
|
@ -730,6 +730,7 @@ Testing more code
|
|||
</div>
|
||||
|
||||
</div>
|
||||
<!--v-if-->
|
||||
</div>
|
||||
<div
|
||||
class="inputWrapper"
|
||||
|
@ -827,6 +828,7 @@ exports[`AskAssistantChat > renders default placeholder chat correctly 1`] = `
|
|||
<div
|
||||
class="body"
|
||||
>
|
||||
<!--v-if-->
|
||||
<div
|
||||
class="placeholder"
|
||||
data-test-id="placeholder-message"
|
||||
|
@ -840,10 +842,13 @@ exports[`AskAssistantChat > renders default placeholder chat correctly 1`] = `
|
|||
class="info"
|
||||
>
|
||||
<p>
|
||||
Hi Kobi, I'm Assistant and I'm here to assist you with building workflows.
|
||||
I'm your Assistant, here to guide you through your journey with n8n.
|
||||
</p>
|
||||
<p>
|
||||
Whenever you encounter a task that I can help with, you'll see the
|
||||
While I'm still learning, I'm already equipped to help you debug any errors you might encounter.
|
||||
</p>
|
||||
<p>
|
||||
If you run into an issue with a node, you'll see the
|
||||
<button
|
||||
class="button"
|
||||
style="height: 18px;"
|
||||
|
@ -896,10 +901,10 @@ exports[`AskAssistantChat > renders default placeholder chat correctly 1`] = `
|
|||
</div>
|
||||
</div>
|
||||
</button>
|
||||
button.
|
||||
button
|
||||
</p>
|
||||
<p>
|
||||
Clicking it starts a chat session with me.
|
||||
Clicking it will start a chat with me, and I'll do my best to assist you!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1131,6 +1136,7 @@ exports[`AskAssistantChat > renders end of session chat correctly 1`] = `
|
|||
</div>
|
||||
|
||||
</div>
|
||||
<!--v-if-->
|
||||
</div>
|
||||
<div
|
||||
class="inputWrapper disabledInput"
|
||||
|
@ -1309,6 +1315,7 @@ exports[`AskAssistantChat > renders streaming chat correctly 1`] = `
|
|||
</div>
|
||||
|
||||
</div>
|
||||
<!--v-if-->
|
||||
</div>
|
||||
<div
|
||||
class="inputWrapper"
|
||||
|
|
|
@ -0,0 +1,28 @@
|
|||
import AssistantLoadingMessage from './AssistantLoadingMessage.vue';
|
||||
import type { StoryFn } from '@storybook/vue3';
|
||||
|
||||
export default {
|
||||
title: 'Assistant/AskAssistantLoadingMessage',
|
||||
component: AssistantLoadingMessage,
|
||||
argTypes: {},
|
||||
};
|
||||
|
||||
const Template: StoryFn = (args, { argTypes }) => ({
|
||||
setup: () => ({ args }),
|
||||
props: Object.keys(argTypes),
|
||||
components: {
|
||||
AssistantLoadingMessage,
|
||||
},
|
||||
template: `<div class="p-xs" style="width: ${args.templateWidth || 'auto'}"><AssistantLoadingMessage v-bind="args" /></div>`,
|
||||
});
|
||||
|
||||
export const Default = Template.bind({});
|
||||
Default.args = {
|
||||
message: 'Searching n8n documentation for the best possible answer...',
|
||||
};
|
||||
|
||||
export const NarrowContainer = Template.bind({});
|
||||
NarrowContainer.args = {
|
||||
...Default.args,
|
||||
templateWidth: '200px',
|
||||
};
|
|
@ -0,0 +1,122 @@
|
|||
<script setup lang="ts">
|
||||
import { defineProps, withDefaults } from 'vue';
|
||||
import AssistantAvatar from '../AskAssistantAvatar/AssistantAvatar.vue';
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
message: string;
|
||||
animationType?: 'slide-vertical' | 'slide-horizontal' | 'fade';
|
||||
}>(),
|
||||
{
|
||||
animationType: 'slide-vertical',
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="$style.container">
|
||||
<div :class="$style.avatar">
|
||||
<AssistantAvatar size="mini" />
|
||||
</div>
|
||||
<div :class="$style['message-container']">
|
||||
<transition :name="animationType" mode="out-in">
|
||||
<span v-if="message" :key="message" :class="$style.message">{{ message }}</span>
|
||||
</transition>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style module lang="scss">
|
||||
.container {
|
||||
display: flex;
|
||||
gap: var(--spacing-3xs);
|
||||
align-items: flex-start;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
animation: pulse 1.5s infinite;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.message-container {
|
||||
display: inline-flex;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
line-height: 1.4rem;
|
||||
}
|
||||
.message {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-weight: var(--font-weight-bold);
|
||||
font-size: var(--font-size-2xs);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
opacity: 0.7;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.2);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
// Vertical Slide transition
|
||||
.slide-vertical-enter-active,
|
||||
.slide-vertical-leave-active {
|
||||
transition:
|
||||
transform 0.5s ease,
|
||||
opacity 0.5s ease;
|
||||
}
|
||||
|
||||
.slide-vertical-enter {
|
||||
transform: translateY(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.slide-vertical-leave-to {
|
||||
transform: translateY(-100%);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
// Horizontal Slide transition
|
||||
.slide-horizontal-enter-active,
|
||||
.slide-horizontal-leave-active {
|
||||
transition:
|
||||
transform 0.5s ease,
|
||||
opacity 0.5s ease;
|
||||
}
|
||||
|
||||
.slide-horizontal-enter {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.slide-horizontal-leave-to {
|
||||
transform: translateX(-100%);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
// Fade transition
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.5s ease;
|
||||
}
|
||||
|
||||
.fade-enter {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.fade-leave-to /* .fade-leave-active in <2.1.8 */ {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,30 @@
|
|||
import DemoComponent from './DemoComponent.vue';
|
||||
import type { StoryFn } from '@storybook/vue3';
|
||||
|
||||
export default {
|
||||
title: 'Assistant/AskAssistantLoadingMessageTransitions',
|
||||
component: DemoComponent,
|
||||
argTypes: {},
|
||||
};
|
||||
|
||||
const Template: StoryFn = (args, { argTypes }) => ({
|
||||
setup: () => ({ args }),
|
||||
props: Object.keys(argTypes),
|
||||
components: {
|
||||
DemoComponent,
|
||||
},
|
||||
template: '<DemoComponent v-bind="args" />',
|
||||
});
|
||||
|
||||
export const Default = Template.bind({});
|
||||
Default.args = {};
|
||||
|
||||
export const Horizontal = Template.bind({});
|
||||
Horizontal.args = {
|
||||
animationType: 'slide-horizontal',
|
||||
};
|
||||
|
||||
export const Fade = Template.bind({});
|
||||
Fade.args = {
|
||||
animationType: 'fade',
|
||||
};
|
|
@ -0,0 +1,55 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import AssistantLoadingMessage from './AssistantLoadingMessage.vue';
|
||||
import Notice from '../N8nNotice/Notice.vue';
|
||||
|
||||
/**
|
||||
* This is a demo component to show how the transitions work in the AssistantLoadingMessage component.
|
||||
* It should be only used in the storybook.
|
||||
*/
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
animationType?: 'slide-vertical' | 'slide-horizontal' | 'fade';
|
||||
}>(),
|
||||
{
|
||||
animationType: 'slide-vertical',
|
||||
},
|
||||
);
|
||||
|
||||
const messages = [
|
||||
'Analyzing the error...',
|
||||
'Searching the n8n documentation...',
|
||||
'Checking the n8n community for answers..',
|
||||
];
|
||||
const currentIndex = ref(0);
|
||||
|
||||
const currentMessage = computed(() => {
|
||||
return messages[currentIndex.value];
|
||||
});
|
||||
|
||||
const startMessageRotation = () => {
|
||||
setInterval(() => {
|
||||
currentIndex.value = (currentIndex.value + 1) % messages.length;
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
startMessageRotation();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<Notice type="warning" content="This component is for demo purposes only" />
|
||||
<div :class="$style['loading-message']">
|
||||
<AssistantLoadingMessage :message="currentMessage" :animation-type="animationType" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style module lang="scss">
|
||||
.loading-message {
|
||||
padding: var(--spacing-3xs);
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,13 @@
|
|||
import { render } from '@testing-library/vue';
|
||||
import AssistantLoadingMessage from '../AssistantLoadingMessage.vue';
|
||||
|
||||
describe('AssistantLoadingMessage', () => {
|
||||
it('renders loading message correctly', () => {
|
||||
const { container } = render(AssistantLoadingMessage, {
|
||||
props: {
|
||||
loadingMessage: 'Thinking...',
|
||||
},
|
||||
});
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,71 @@
|
|||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`AssistantLoadingMessage > renders loading message correctly 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="container"
|
||||
data-v-4e90e01e=""
|
||||
loadingmessage="Thinking..."
|
||||
>
|
||||
<div
|
||||
class="avatar"
|
||||
data-v-4e90e01e=""
|
||||
>
|
||||
<div
|
||||
class="container mini"
|
||||
data-v-4e90e01e=""
|
||||
>
|
||||
<svg
|
||||
fill="none"
|
||||
height="8"
|
||||
viewBox="0 0 24 24"
|
||||
width="8"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M19.9658 14.0171C19.9679 14.3549 19.8654 14.6851 19.6722 14.9622C19.479 15.2393 19.2046 15.4497 18.8869 15.5645L13.5109 17.5451L11.5303 22.9211C11.4137 23.2376 11.2028 23.5107 10.9261 23.7037C10.6494 23.8966 10.3202 24 9.9829 24C9.64559 24 9.3164 23.8966 9.0397 23.7037C8.76301 23.5107 8.55212 23.2376 8.43549 22.9211L6.45487 17.5451L1.07888 15.5645C0.762384 15.4479 0.489262 15.237 0.296347 14.9603C0.103431 14.6836 0 14.3544 0 14.0171C0 13.6798 0.103431 13.3506 0.296347 13.0739C0.489262 12.7972 0.762384 12.5863 1.07888 12.4697L6.45487 10.4891L8.43549 5.11309C8.55212 4.79659 8.76301 4.52347 9.0397 4.33055C9.3164 4.13764 9.64559 4.0342 9.9829 4.0342C10.3202 4.0342 10.6494 4.13764 10.9261 4.33055C11.2028 4.52347 11.4137 4.79659 11.5303 5.11309L13.5109 10.4891L18.8869 12.4697C19.2046 12.5845 19.479 12.7949 19.6722 13.072C19.8654 13.3491 19.9679 13.6793 19.9658 14.0171ZM14.1056 4.12268H15.7546V5.77175C15.7546 5.99043 15.8415 6.20015 15.9961 6.35478C16.1508 6.50941 16.3605 6.59628 16.5792 6.59628C16.7979 6.59628 17.0076 6.50941 17.1622 6.35478C17.3168 6.20015 17.4037 5.99043 17.4037 5.77175V4.12268H19.0528C19.2715 4.12268 19.4812 4.03581 19.6358 3.88118C19.7905 3.72655 19.8773 3.51682 19.8773 3.29814C19.8773 3.07946 19.7905 2.86974 19.6358 2.71511C19.4812 2.56048 19.2715 2.47361 19.0528 2.47361H17.4037V0.824535C17.4037 0.605855 17.3168 0.396131 17.1622 0.241501C17.0076 0.0868704 16.7979 0 16.5792 0C16.3605 0 16.1508 0.0868704 15.9961 0.241501C15.8415 0.396131 15.7546 0.605855 15.7546 0.824535V2.47361H14.1056C13.8869 2.47361 13.6772 2.56048 13.5225 2.71511C13.3679 2.86974 13.281 3.07946 13.281 3.29814C13.281 3.51682 13.3679 3.72655 13.5225 3.88118C13.6772 4.03581 13.8869 4.12268 14.1056 4.12268ZM23.1755 7.42082H22.3509V6.59628C22.3509 6.3776 22.2641 6.16788 22.1094 6.01325C21.9548 5.85862 21.7451 5.77175 21.5264 5.77175C21.3077 5.77175 21.098 5.85862 20.9434 6.01325C20.7887 6.16788 20.7019 6.3776 20.7019 6.59628V7.42082H19.8773C19.6586 7.42082 19.4489 7.50769 19.2943 7.66232C19.1397 7.81695 19.0528 8.02667 19.0528 8.24535C19.0528 8.46404 19.1397 8.67376 19.2943 8.82839C19.4489 8.98302 19.6586 9.06989 19.8773 9.06989H20.7019V9.89443C20.7019 10.1131 20.7887 10.3228 20.9434 10.4775C21.098 10.6321 21.3077 10.719 21.5264 10.719C21.7451 10.719 21.9548 10.6321 22.1094 10.4775C22.2641 10.3228 22.3509 10.1131 22.3509 9.89443V9.06989H23.1755C23.3941 9.06989 23.6039 8.98302 23.7585 8.82839C23.9131 8.67376 24 8.46404 24 8.24535C24 8.02667 23.9131 7.81695 23.7585 7.66232C23.6039 7.50769 23.3941 7.42082 23.1755 7.42082Z"
|
||||
fill="white"
|
||||
/>
|
||||
<defs>
|
||||
<lineargradient
|
||||
gradientUnits="userSpaceOnUse"
|
||||
id="paint0_linear_173_12825"
|
||||
x1="-3.67094e-07"
|
||||
x2="28.8315"
|
||||
y1="-0.000120994"
|
||||
y2="9.82667"
|
||||
>
|
||||
<stop
|
||||
stop-color="var(--color-assistant-highlight-1)"
|
||||
/>
|
||||
<stop
|
||||
offset="0.495"
|
||||
stop-color="var(--color-assistant-highlight-2)"
|
||||
/>
|
||||
<stop
|
||||
offset="1"
|
||||
stop-color="var(--color-assistant-highlight-3)"
|
||||
/>
|
||||
</lineargradient>
|
||||
</defs>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="message-container"
|
||||
data-v-4e90e01e=""
|
||||
>
|
||||
<transition-stub
|
||||
appear="false"
|
||||
css="true"
|
||||
data-v-4e90e01e=""
|
||||
mode="out-in"
|
||||
name="slide-vertical"
|
||||
persisted="false"
|
||||
>
|
||||
<!--v-if-->
|
||||
</transition-stub>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
|
@ -38,12 +38,14 @@ export default {
|
|||
'assistantChat.sessionEndMessage.2': 'button in n8n',
|
||||
'assistantChat.you': 'You',
|
||||
'assistantChat.quickRepliesTitle': 'Quick reply 👇',
|
||||
'assistantChat.placeholder.1': (options: string[]) =>
|
||||
`Hi ${options[0][0] || 'there'}, I'm ${options[0][1]} and I'm here to assist you with building workflows.`,
|
||||
'assistantChat.placeholder.1': () =>
|
||||
"I'm your Assistant, here to guide you through your journey with n8n.",
|
||||
'assistantChat.placeholder.2':
|
||||
"Whenever you encounter a task that I can help with, you'll see the",
|
||||
'assistantChat.placeholder.3': 'button.',
|
||||
'assistantChat.placeholder.4': 'Clicking it starts a chat session with me.',
|
||||
"While I'm still learning, I'm already equipped to help you debug any errors you might encounter.",
|
||||
'assistantChat.placeholder.3': "If you run into an issue with a node, you'll see the",
|
||||
'assistantChat.placeholder.4': 'button',
|
||||
'assistantChat.placeholder.5':
|
||||
"Clicking it will start a chat with me, and I'll do my best to assist you!",
|
||||
'assistantChat.inputPlaceholder': 'Enter your response...',
|
||||
'inlineAskAssistantButton.asked': 'Asked',
|
||||
} as N8nLocale;
|
||||
|
|
|
@ -32,6 +32,7 @@ export namespace ChatUI {
|
|||
export interface QuickReply {
|
||||
type: string;
|
||||
text: string;
|
||||
isFeedback?: boolean;
|
||||
}
|
||||
|
||||
export interface ErrorMessage {
|
||||
|
|
|
@ -16,6 +16,8 @@ const user = computed(() => ({
|
|||
lastName: usersStore.currentUser?.lastName ?? '',
|
||||
}));
|
||||
|
||||
const loadingMessage = computed(() => assistantStore.assistantThinkingMessage);
|
||||
|
||||
function onResize(data: { direction: string; x: number; width: number }) {
|
||||
assistantStore.updateWindowWidth(data.width);
|
||||
}
|
||||
|
@ -24,7 +26,7 @@ function onResizeDebounced(data: { direction: string; x: number; width: number }
|
|||
void useDebounce().callDebounced(onResize, { debounceTime: 10, trailing: true }, data);
|
||||
}
|
||||
|
||||
async function onUserMessage(content: string, quickReplyType?: string) {
|
||||
async function onUserMessage(content: string, quickReplyType?: string, isFeedback = false) {
|
||||
await assistantStore.sendMessage({ text: content, quickReplyType });
|
||||
const task = 'error';
|
||||
const solutionCount =
|
||||
|
@ -33,9 +35,10 @@ async function onUserMessage(content: string, quickReplyType?: string) {
|
|||
(msg) => msg.role === 'assistant' && !['text', 'event'].includes(msg.type),
|
||||
).length
|
||||
: null;
|
||||
if (quickReplyType === 'all-good' || quickReplyType === 'still-stuck') {
|
||||
if (isFeedback) {
|
||||
telemetry.track('User gave feedback', {
|
||||
task,
|
||||
chat_session_id: assistantStore.currentSessionId,
|
||||
is_quick_reply: !!quickReplyType,
|
||||
is_positive: quickReplyType === 'all-good',
|
||||
solution_count: solutionCount,
|
||||
|
@ -83,6 +86,8 @@ function onClose() {
|
|||
:user="user"
|
||||
:messages="assistantStore.chatMessages"
|
||||
:streaming="assistantStore.streaming"
|
||||
:loading-message="loadingMessage"
|
||||
:session-id="assistantStore.currentSessionId"
|
||||
@close="onClose"
|
||||
@message="onUserMessage"
|
||||
@code-replace="onCodeReplace"
|
||||
|
|
|
@ -0,0 +1,18 @@
|
|||
<script lang="ts" setup>
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
const i18 = useI18n();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<a data-action="reload">{{ i18.baseText('nodeView.refresh') }}</a>
|
||||
{{ i18.baseText('nodeView.toSeeTheLatestStatus') }}.
|
||||
<br />
|
||||
<a
|
||||
href="https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.wait/"
|
||||
target="_blank"
|
||||
>
|
||||
{{ i18.baseText('nodeView.moreInfo') }}
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
|
@ -22,7 +22,7 @@ const messageDefaults: Partial<Omit<NotificationOptions, 'message'>> = {
|
|||
position: 'bottom-right',
|
||||
zIndex: 1900, // above NDV and below the modals
|
||||
offset: 64,
|
||||
appendTo: '#node-view-root',
|
||||
appendTo: '#app-grid',
|
||||
customClass: 'content-toast',
|
||||
};
|
||||
|
||||
|
|
|
@ -147,6 +147,8 @@
|
|||
"aiAssistant.serviceError.message": "Unable to connect to n8n's AI service",
|
||||
"aiAssistant.codeUpdated.message.title": "Assistant modified workflow",
|
||||
"aiAssistant.codeUpdated.message.body": "Open the <a data-action='openNodeDetail' data-action-parameter-node='{nodeName}'>{nodeName}</a> node to see the changes",
|
||||
"aiAssistant.thinkingSteps.analyzingError": "Analyzing the error...",
|
||||
"aiAssistant.thinkingSteps.thinking": "Thinking...",
|
||||
"banners.confirmEmail.message.1": "To secure your account and prevent future access issues, please confirm your",
|
||||
"banners.confirmEmail.message.2": "email address.",
|
||||
"banners.confirmEmail.button": "Confirm email",
|
||||
|
|
|
@ -311,7 +311,6 @@ describe('AI Assistant store', () => {
|
|||
};
|
||||
const assistantStore = useAssistantStore();
|
||||
await assistantStore.initErrorHelper(context);
|
||||
expect(assistantStore.chatMessages.length).toBe(2);
|
||||
expect(apiSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -31,7 +31,7 @@ import { useUIStore } from './ui.store';
|
|||
|
||||
export const MAX_CHAT_WIDTH = 425;
|
||||
export const MIN_CHAT_WIDTH = 250;
|
||||
export const DEFAULT_CHAT_WIDTH = 325;
|
||||
export const DEFAULT_CHAT_WIDTH = 330;
|
||||
export const ENABLED_VIEWS = [...EDITABLE_CANVAS_VIEWS, VIEWS.EXECUTION_PREVIEW];
|
||||
const READABLE_TYPES = ['code-diff', 'text', 'block'];
|
||||
|
||||
|
@ -63,6 +63,10 @@ export const useAssistantStore = defineStore(STORES.ASSISTANT, () => {
|
|||
const currentSessionActiveExecutionId = ref<string | undefined>();
|
||||
const currentSessionWorkflowId = ref<string | undefined>();
|
||||
const lastUnread = ref<ChatUI.AssistantMessage | undefined>();
|
||||
const nodeExecutionStatus = ref<'not_executed' | 'success' | 'error'>('not_executed');
|
||||
// This is used to show a message when the assistant is performing intermediate steps
|
||||
// We use streaming for assistants that support it, and this for agents
|
||||
const assistantThinkingMessage = ref<string | undefined>();
|
||||
|
||||
const isExperimentEnabled = computed(
|
||||
() => getVariant(AI_ASSISTANT_EXPERIMENT.name) === AI_ASSISTANT_EXPERIMENT.variant,
|
||||
|
@ -117,6 +121,7 @@ export const useAssistantStore = defineStore(STORES.ASSISTANT, () => {
|
|||
lastUnread.value = undefined;
|
||||
currentSessionActiveExecutionId.value = undefined;
|
||||
suggestions.value = {};
|
||||
nodeExecutionStatus.value = 'not_executed';
|
||||
}
|
||||
|
||||
// As assistant sidebar opens and closes, use window width to calculate the container width
|
||||
|
@ -140,6 +145,7 @@ export const useAssistantStore = defineStore(STORES.ASSISTANT, () => {
|
|||
const messages = [...chatMessages.value].filter(
|
||||
(msg) => !(msg.id === id && msg.role === 'assistant'),
|
||||
);
|
||||
assistantThinkingMessage.value = undefined;
|
||||
// TODO: simplify
|
||||
assistantMessages.forEach((msg) => {
|
||||
if (msg.type === 'message') {
|
||||
|
@ -190,6 +196,8 @@ export const useAssistantStore = defineStore(STORES.ASSISTANT, () => {
|
|||
quickReplies: msg.quickReplies,
|
||||
read,
|
||||
});
|
||||
} else if (msg.type === 'intermediate-step') {
|
||||
assistantThinkingMessage.value = msg.text;
|
||||
}
|
||||
});
|
||||
chatMessages.value = messages;
|
||||
|
@ -226,14 +234,8 @@ export const useAssistantStore = defineStore(STORES.ASSISTANT, () => {
|
|||
});
|
||||
}
|
||||
|
||||
function addEmptyAssistantMessage(id: string) {
|
||||
chatMessages.value.push({
|
||||
id,
|
||||
role: 'assistant',
|
||||
type: 'text',
|
||||
content: '',
|
||||
read: false,
|
||||
});
|
||||
function addLoadingAssistantMessage(message: string) {
|
||||
assistantThinkingMessage.value = message;
|
||||
}
|
||||
|
||||
function addUserMessage(content: string, id: string) {
|
||||
|
@ -249,6 +251,7 @@ export const useAssistantStore = defineStore(STORES.ASSISTANT, () => {
|
|||
function handleServiceError(e: unknown, id: string) {
|
||||
assert(e instanceof Error);
|
||||
stopStreaming();
|
||||
assistantThinkingMessage.value = undefined;
|
||||
addAssistantError(`${locale.baseText('aiAssistant.serviceError.message')}: (${e.message})`, id);
|
||||
}
|
||||
|
||||
|
@ -316,7 +319,7 @@ export const useAssistantStore = defineStore(STORES.ASSISTANT, () => {
|
|||
const availableAuthOptions = getNodeAuthOptions(nodeType);
|
||||
authType = availableAuthOptions.find((option) => option.value === credentialInUse);
|
||||
}
|
||||
addEmptyAssistantMessage(id);
|
||||
addLoadingAssistantMessage(locale.baseText('aiAssistant.thinkingSteps.analyzingError'));
|
||||
openChat();
|
||||
|
||||
streaming.value = true;
|
||||
|
@ -351,7 +354,7 @@ export const useAssistantStore = defineStore(STORES.ASSISTANT, () => {
|
|||
assert(currentSessionId.value);
|
||||
|
||||
const id = getRandomId();
|
||||
addEmptyAssistantMessage(id);
|
||||
addLoadingAssistantMessage(locale.baseText('aiAssistant.thinkingSteps.thinking'));
|
||||
streaming.value = true;
|
||||
chatWithAssistant(
|
||||
rootStore.restApiContext,
|
||||
|
@ -369,21 +372,30 @@ export const useAssistantStore = defineStore(STORES.ASSISTANT, () => {
|
|||
(e) => handleServiceError(e, id),
|
||||
);
|
||||
}
|
||||
|
||||
async function onNodeExecution(pushEvent: IPushDataNodeExecuteAfter) {
|
||||
if (!chatSessionError.value || pushEvent.nodeName !== chatSessionError.value.node.name) {
|
||||
return;
|
||||
}
|
||||
if (pushEvent.data.error) {
|
||||
if (pushEvent.data.error && nodeExecutionStatus.value !== 'error') {
|
||||
await sendEvent('node-execution-errored', pushEvent.data.error);
|
||||
} else if (pushEvent.data.executionStatus === 'success') {
|
||||
nodeExecutionStatus.value = 'error';
|
||||
telemetry.track('User executed node after assistant suggestion', {
|
||||
task: 'error',
|
||||
chat_session_id: currentSessionId.value,
|
||||
success: false,
|
||||
});
|
||||
} else if (
|
||||
pushEvent.data.executionStatus === 'success' &&
|
||||
nodeExecutionStatus.value !== 'success'
|
||||
) {
|
||||
await sendEvent('node-execution-succeeded');
|
||||
nodeExecutionStatus.value = 'success';
|
||||
telemetry.track('User executed node after assistant suggestion', {
|
||||
task: 'error',
|
||||
chat_session_id: currentSessionId.value,
|
||||
success: true,
|
||||
});
|
||||
}
|
||||
telemetry.track('User executed node after assistant suggestion', {
|
||||
task: 'error',
|
||||
chat_session_id: currentSessionId.value,
|
||||
success: pushEvent.data.executionStatus === 'success',
|
||||
});
|
||||
}
|
||||
|
||||
async function sendMessage(
|
||||
|
@ -396,10 +408,16 @@ export const useAssistantStore = defineStore(STORES.ASSISTANT, () => {
|
|||
const id = getRandomId();
|
||||
try {
|
||||
addUserMessage(chatMessage.text, id);
|
||||
addEmptyAssistantMessage(id);
|
||||
addLoadingAssistantMessage(locale.baseText('aiAssistant.thinkingSteps.thinking'));
|
||||
|
||||
streaming.value = true;
|
||||
assert(currentSessionId.value);
|
||||
if (
|
||||
chatMessage.quickReplyType === 'new-suggestion' &&
|
||||
nodeExecutionStatus.value !== 'not_executed'
|
||||
) {
|
||||
nodeExecutionStatus.value = 'not_executed';
|
||||
}
|
||||
chatWithAssistant(
|
||||
rootStore.restApiContext,
|
||||
{
|
||||
|
@ -415,6 +433,12 @@ export const useAssistantStore = defineStore(STORES.ASSISTANT, () => {
|
|||
() => onDoneStreaming(id),
|
||||
(e) => handleServiceError(e, id),
|
||||
);
|
||||
telemetry.track('User sent message in Assistant', {
|
||||
message: chatMessage.text,
|
||||
is_quick_reply: !!chatMessage.quickReplyType,
|
||||
chat_session_id: currentSessionId.value,
|
||||
message_number: chatMessages.value.filter((msg) => msg.role === 'user').length,
|
||||
});
|
||||
} catch (e: unknown) {
|
||||
// in case of assert
|
||||
handleServiceError(e, id);
|
||||
|
@ -566,5 +590,6 @@ export const useAssistantStore = defineStore(STORES.ASSISTANT, () => {
|
|||
resetAssistantChat,
|
||||
chatWindowOpen,
|
||||
addAssistantMessages,
|
||||
assistantThinkingMessage,
|
||||
};
|
||||
});
|
||||
|
|
|
@ -76,6 +76,7 @@ export namespace ChatRequest {
|
|||
role: 'assistant';
|
||||
type: 'message';
|
||||
text: string;
|
||||
step?: 'n8n_documentation' | 'n8n_forum';
|
||||
}
|
||||
|
||||
interface AssistantSummaryMessage {
|
||||
|
@ -98,8 +99,21 @@ export namespace ChatRequest {
|
|||
text: string;
|
||||
}
|
||||
|
||||
interface AgentThinkingStep {
|
||||
role: 'assistant';
|
||||
type: 'intermediate-step';
|
||||
text: string;
|
||||
step: string;
|
||||
}
|
||||
|
||||
export type MessageResponse =
|
||||
| ((AssistantChatMessage | CodeDiffMessage | AssistantSummaryMessage | AgentChatMessage) & {
|
||||
| ((
|
||||
| AssistantChatMessage
|
||||
| CodeDiffMessage
|
||||
| AssistantSummaryMessage
|
||||
| AgentChatMessage
|
||||
| AgentThinkingStep
|
||||
) & {
|
||||
quickReplies?: QuickReplyOption[];
|
||||
})
|
||||
| EndSessionMessage;
|
||||
|
|
|
@ -11,6 +11,7 @@ import {
|
|||
ref,
|
||||
useCssModule,
|
||||
watch,
|
||||
h,
|
||||
} from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import WorkflowCanvas from '@/components/canvas/WorkflowCanvas.vue';
|
||||
|
@ -97,6 +98,7 @@ import type { PinDataSource } from '@/composables/usePinnedData';
|
|||
import { useClipboard } from '@/composables/useClipboard';
|
||||
import { useBeforeUnload } from '@/composables/useBeforeUnload';
|
||||
import { getResourcePermissions } from '@/permissions';
|
||||
import NodeViewUnfinishedWorkflowMessage from '@/components/NodeViewUnfinishedWorkflowMessage.vue';
|
||||
|
||||
const LazyNodeCreation = defineAsyncComponent(
|
||||
async () => await import('@/components/Node/NodeCreation.vue'),
|
||||
|
@ -1062,11 +1064,7 @@ function onExecutionOpenedWithWaitTill(data: IExecutionResponse) {
|
|||
if ((data as ExecutionSummary).waitTill) {
|
||||
toast.showMessage({
|
||||
title: i18n.baseText('nodeView.thisExecutionHasntFinishedYet'),
|
||||
message: `<a data-action="reload">${i18n.baseText('nodeView.refresh')}</a> ${i18n.baseText(
|
||||
'nodeView.toSeeTheLatestStatus',
|
||||
)}.<br/> <a href="https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.wait/" target="_blank">${i18n.baseText(
|
||||
'nodeView.moreInfo',
|
||||
)}</a>`,
|
||||
message: h(NodeViewUnfinishedWorkflowMessage),
|
||||
type: 'warning',
|
||||
duration: 0,
|
||||
});
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<script lang="ts">
|
||||
import { defineAsyncComponent, defineComponent, nextTick, ref } from 'vue';
|
||||
import { defineAsyncComponent, defineComponent, nextTick, ref, h } from 'vue';
|
||||
import { mapStores, storeToRefs } from 'pinia';
|
||||
|
||||
import type {
|
||||
|
@ -182,6 +182,7 @@ import { usePostHog } from '@/stores/posthog.store';
|
|||
import { useNpsSurveyStore } from '@/stores/npsSurvey.store';
|
||||
import { getResourcePermissions } from '@/permissions';
|
||||
import { useBeforeUnload } from '@/composables/useBeforeUnload';
|
||||
import NodeViewUnfinishedWorkflowMessage from '@/components/NodeViewUnfinishedWorkflowMessage.vue';
|
||||
|
||||
interface AddNodeOptions {
|
||||
position?: XYPosition;
|
||||
|
@ -1063,13 +1064,7 @@ export default defineComponent({
|
|||
if ((data as ExecutionSummary).waitTill) {
|
||||
this.showMessage({
|
||||
title: this.$locale.baseText('nodeView.thisExecutionHasntFinishedYet'),
|
||||
message: `<a data-action="reload">${this.$locale.baseText(
|
||||
'nodeView.refresh',
|
||||
)}</a> ${this.$locale.baseText(
|
||||
'nodeView.toSeeTheLatestStatus',
|
||||
)}.<br/> <a href="https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.wait/" target="_blank">${this.$locale.baseText(
|
||||
'nodeView.moreInfo',
|
||||
)}</a>`,
|
||||
message: h(NodeViewUnfinishedWorkflowMessage),
|
||||
type: 'warning',
|
||||
duration: 0,
|
||||
});
|
||||
|
|
203
packages/editor-ui/src/views/SettingsSso.test.ts
Normal file
203
packages/editor-ui/src/views/SettingsSso.test.ts
Normal file
|
@ -0,0 +1,203 @@
|
|||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import SettingsSso from './SettingsSso.vue';
|
||||
import { useSSOStore } from '@/stores/sso.store';
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
import { within, waitFor } from '@testing-library/vue';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { mockedStore } from '@/__tests__/utils';
|
||||
|
||||
const renderView = createComponentRenderer(SettingsSso);
|
||||
|
||||
const samlConfig = {
|
||||
metadata: 'metadata dummy',
|
||||
metadataUrl:
|
||||
'https://dev-qqkrykgkoo0p63d5.eu.auth0.com/samlp/metadata/KR1cSrRrxaZT2gV8ZhPAUIUHtEY4duhN',
|
||||
entityID: 'https://n8n-tunnel.myhost.com/rest/sso/saml/metadata',
|
||||
returnUrl: 'https://n8n-tunnel.myhost.com/rest/sso/saml/acs',
|
||||
};
|
||||
|
||||
const telemetryTrack = vi.fn();
|
||||
vi.mock('@/composables/useTelemetry', () => ({
|
||||
useTelemetry: () => ({
|
||||
track: telemetryTrack,
|
||||
}),
|
||||
}));
|
||||
|
||||
const showError = vi.fn();
|
||||
vi.mock('@/composables/useToast', () => ({
|
||||
useToast: () => ({
|
||||
showError,
|
||||
}),
|
||||
}));
|
||||
|
||||
const confirmMessage = vi.fn();
|
||||
vi.mock('@/composables/useMessage', () => ({
|
||||
useMessage: () => ({
|
||||
confirm: confirmMessage,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('SettingsSso View', () => {
|
||||
beforeEach(() => {
|
||||
telemetryTrack.mockReset();
|
||||
confirmMessage.mockReset();
|
||||
showError.mockReset();
|
||||
});
|
||||
|
||||
it('should show upgrade banner when enterprise SAML is disabled', async () => {
|
||||
const pinia = createTestingPinia();
|
||||
const ssoStore = mockedStore(useSSOStore);
|
||||
ssoStore.isEnterpriseSamlEnabled = false;
|
||||
|
||||
const uiStore = useUIStore();
|
||||
|
||||
const { getByTestId } = renderView({ pinia });
|
||||
|
||||
const actionBox = getByTestId('sso-content-unlicensed');
|
||||
expect(actionBox).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(await within(actionBox).findByText('See plans'));
|
||||
expect(uiStore.goToUpgrade).toHaveBeenCalledWith('sso', 'upgrade-sso');
|
||||
});
|
||||
|
||||
it('should show user SSO config', async () => {
|
||||
const pinia = createTestingPinia();
|
||||
|
||||
const ssoStore = mockedStore(useSSOStore);
|
||||
ssoStore.isEnterpriseSamlEnabled = true;
|
||||
|
||||
ssoStore.getSamlConfig.mockResolvedValue(samlConfig);
|
||||
|
||||
const { getAllByTestId } = renderView({ pinia });
|
||||
|
||||
expect(ssoStore.getSamlConfig).toHaveBeenCalledTimes(1);
|
||||
|
||||
await waitFor(async () => {
|
||||
const copyInputs = getAllByTestId('copy-input');
|
||||
expect(copyInputs[0].textContent).toContain(samlConfig.returnUrl);
|
||||
expect(copyInputs[1].textContent).toContain(samlConfig.entityID);
|
||||
});
|
||||
});
|
||||
|
||||
it('allows user to toggle SSO', async () => {
|
||||
const pinia = createTestingPinia();
|
||||
|
||||
const ssoStore = mockedStore(useSSOStore);
|
||||
ssoStore.isEnterpriseSamlEnabled = true;
|
||||
ssoStore.isSamlLoginEnabled = false;
|
||||
|
||||
ssoStore.getSamlConfig.mockResolvedValue(samlConfig);
|
||||
|
||||
const { getByTestId } = renderView({ pinia });
|
||||
|
||||
const toggle = getByTestId('sso-toggle');
|
||||
|
||||
expect(toggle.textContent).toContain('Deactivated');
|
||||
|
||||
await userEvent.click(toggle);
|
||||
expect(toggle.textContent).toContain('Activated');
|
||||
|
||||
await userEvent.click(toggle);
|
||||
expect(toggle.textContent).toContain('Deactivated');
|
||||
});
|
||||
|
||||
it("allows user to fill Identity Provider's URL", async () => {
|
||||
confirmMessage.mockResolvedValueOnce('confirm');
|
||||
|
||||
const pinia = createTestingPinia();
|
||||
const windowOpenSpy = vi.spyOn(window, 'open');
|
||||
|
||||
const ssoStore = mockedStore(useSSOStore);
|
||||
ssoStore.isEnterpriseSamlEnabled = true;
|
||||
|
||||
const { getByTestId } = renderView({ pinia });
|
||||
|
||||
const saveButton = getByTestId('sso-save');
|
||||
expect(saveButton).toBeDisabled();
|
||||
|
||||
const urlinput = getByTestId('sso-provider-url');
|
||||
|
||||
expect(urlinput).toBeVisible();
|
||||
await userEvent.type(urlinput, samlConfig.metadataUrl);
|
||||
|
||||
expect(saveButton).not.toBeDisabled();
|
||||
await userEvent.click(saveButton);
|
||||
|
||||
expect(ssoStore.saveSamlConfig).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ metadataUrl: samlConfig.metadataUrl }),
|
||||
);
|
||||
|
||||
expect(ssoStore.testSamlConfig).toHaveBeenCalled();
|
||||
expect(windowOpenSpy).toHaveBeenCalled();
|
||||
|
||||
expect(telemetryTrack).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({ identity_provider: 'metadata' }),
|
||||
);
|
||||
|
||||
expect(ssoStore.getSamlConfig).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("allows user to fill Identity Provider's XML", async () => {
|
||||
confirmMessage.mockResolvedValueOnce('confirm');
|
||||
|
||||
const pinia = createTestingPinia();
|
||||
const windowOpenSpy = vi.spyOn(window, 'open');
|
||||
|
||||
const ssoStore = mockedStore(useSSOStore);
|
||||
ssoStore.isEnterpriseSamlEnabled = true;
|
||||
|
||||
const { getByTestId } = renderView({ pinia });
|
||||
|
||||
const saveButton = getByTestId('sso-save');
|
||||
expect(saveButton).toBeDisabled();
|
||||
|
||||
await userEvent.click(getByTestId('radio-button-xml'));
|
||||
|
||||
const xmlInput = getByTestId('sso-provider-xml');
|
||||
|
||||
expect(xmlInput).toBeVisible();
|
||||
await userEvent.type(xmlInput, samlConfig.metadata);
|
||||
|
||||
expect(saveButton).not.toBeDisabled();
|
||||
await userEvent.click(saveButton);
|
||||
|
||||
expect(ssoStore.saveSamlConfig).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ metadata: samlConfig.metadata }),
|
||||
);
|
||||
|
||||
expect(ssoStore.testSamlConfig).toHaveBeenCalled();
|
||||
expect(windowOpenSpy).toHaveBeenCalled();
|
||||
|
||||
expect(telemetryTrack).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({ identity_provider: 'xml' }),
|
||||
);
|
||||
|
||||
expect(ssoStore.getSamlConfig).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('PAY-1812: allows user to disable SSO even if config request failed', async () => {
|
||||
const pinia = createTestingPinia();
|
||||
|
||||
const ssoStore = mockedStore(useSSOStore);
|
||||
ssoStore.isEnterpriseSamlEnabled = true;
|
||||
ssoStore.isSamlLoginEnabled = true;
|
||||
|
||||
const error = new Error('Request failed with status code 404');
|
||||
ssoStore.getSamlConfig.mockRejectedValue(error);
|
||||
|
||||
const { getByTestId } = renderView({ pinia });
|
||||
|
||||
expect(ssoStore.getSamlConfig).toHaveBeenCalledTimes(1);
|
||||
|
||||
await waitFor(async () => {
|
||||
expect(showError).toHaveBeenCalledWith(error, 'error');
|
||||
const toggle = getByTestId('sso-toggle');
|
||||
expect(toggle.textContent).toContain('Activated');
|
||||
await userEvent.click(toggle);
|
||||
expect(toggle.textContent).toContain('Deactivated');
|
||||
});
|
||||
});
|
||||
});
|
|
@ -134,6 +134,15 @@ const goToUpgrade = () => {
|
|||
void uiStore.goToUpgrade('sso', 'upgrade-sso');
|
||||
};
|
||||
|
||||
const isToggleSsoDisabled = computed(() => {
|
||||
/** Allow users to disable SSO even if config request fails */
|
||||
if (ssoStore.isSamlLoginEnabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !ssoSettingsSaved.value;
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
if (!ssoStore.isEnterpriseSamlEnabled) {
|
||||
return;
|
||||
|
@ -162,7 +171,8 @@ onMounted(async () => {
|
|||
</template>
|
||||
<el-switch
|
||||
v-model="ssoStore.isSamlLoginEnabled"
|
||||
:disabled="!ssoSettingsSaved"
|
||||
data-test-id="sso-toggle"
|
||||
:disabled="isToggleSsoDisabled"
|
||||
:class="$style.switch"
|
||||
:inactive-text="ssoActivatedLabel"
|
||||
/>
|
||||
|
@ -205,11 +215,18 @@ onMounted(async () => {
|
|||
name="metadataUrl"
|
||||
size="large"
|
||||
:placeholder="i18n.baseText('settings.sso.settings.ips.url.placeholder')"
|
||||
data-test-id="sso-provider-url"
|
||||
/>
|
||||
<small>{{ i18n.baseText('settings.sso.settings.ips.url.help') }}</small>
|
||||
</div>
|
||||
<div v-show="ipsType === IdentityProviderSettingsType.XML">
|
||||
<n8n-input v-model="metadata" type="textarea" name="metadata" :rows="4" />
|
||||
<n8n-input
|
||||
v-model="metadata"
|
||||
type="textarea"
|
||||
name="metadata"
|
||||
:rows="4"
|
||||
data-test-id="sso-provider-xml"
|
||||
/>
|
||||
<small>{{ i18n.baseText('settings.sso.settings.ips.xml.help') }}</small>
|
||||
</div>
|
||||
</div>
|
||||
|
|
Loading…
Reference in a new issue