mirror of
https://github.com/n8n-io/n8n.git
synced 2025-03-05 20:50:17 -08:00
feat: Add credential help to Assistant (no-changelog) (#10736)
This commit is contained in:
parent
50459bacab
commit
2bc983ba32
|
@ -1,10 +1,13 @@
|
||||||
import { SCHEDULE_TRIGGER_NODE_NAME } from '../constants';
|
import { clickCreateNewCredential, openCredentialSelect } from '../composables/ndv';
|
||||||
import { NDV, WorkflowPage } from '../pages';
|
import { GMAIL_NODE_NAME, SCHEDULE_TRIGGER_NODE_NAME } from '../constants';
|
||||||
|
import { CredentialsModal, CredentialsPage, NDV, WorkflowPage } from '../pages';
|
||||||
import { AIAssistant } from '../pages/features/ai-assistant';
|
import { AIAssistant } from '../pages/features/ai-assistant';
|
||||||
|
|
||||||
const wf = new WorkflowPage();
|
const wf = new WorkflowPage();
|
||||||
const ndv = new NDV();
|
const ndv = new NDV();
|
||||||
const aiAssistant = new AIAssistant();
|
const aiAssistant = new AIAssistant();
|
||||||
|
const credentialsPage = new CredentialsPage();
|
||||||
|
const credentialsModal = new CredentialsModal();
|
||||||
|
|
||||||
describe('AI Assistant::disabled', () => {
|
describe('AI Assistant::disabled', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
@ -303,3 +306,71 @@ describe('AI Assistant::enabled', () => {
|
||||||
aiAssistant.getters.placeholderMessage().should('not.exist');
|
aiAssistant.getters.placeholderMessage().should('not.exist');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('AI Assistant Credential Help', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
aiAssistant.actions.enableAssistant();
|
||||||
|
wf.actions.visit();
|
||||||
|
});
|
||||||
|
|
||||||
|
after(() => {
|
||||||
|
aiAssistant.actions.disableAssistant();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should start credential help from node credential', () => {
|
||||||
|
cy.intercept('POST', '/rest/ai-assistant/chat', {
|
||||||
|
statusCode: 200,
|
||||||
|
fixture: 'aiAssistant/simple_message_response.json',
|
||||||
|
}).as('chatRequest');
|
||||||
|
wf.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
|
||||||
|
wf.actions.addNodeToCanvas(GMAIL_NODE_NAME);
|
||||||
|
wf.actions.openNode('Gmail');
|
||||||
|
openCredentialSelect();
|
||||||
|
clickCreateNewCredential();
|
||||||
|
aiAssistant.getters.credentialEditAssistantButton().should('be.visible');
|
||||||
|
aiAssistant.getters.credentialEditAssistantButton().click();
|
||||||
|
cy.wait('@chatRequest');
|
||||||
|
aiAssistant.getters.chatMessagesUser().should('have.length', 1);
|
||||||
|
aiAssistant.getters
|
||||||
|
.chatMessagesUser()
|
||||||
|
.eq(0)
|
||||||
|
.should('contain.text', 'How do I set up the credentials for Gmail OAuth2 API?');
|
||||||
|
|
||||||
|
aiAssistant.getters
|
||||||
|
.chatMessagesAssistant()
|
||||||
|
.eq(0)
|
||||||
|
.should('contain.text', 'Hey, this is an assistant message');
|
||||||
|
aiAssistant.getters.credentialEditAssistantButton().should('be.disabled');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should start credential help from credential list', () => {
|
||||||
|
cy.intercept('POST', '/rest/ai-assistant/chat', {
|
||||||
|
statusCode: 200,
|
||||||
|
fixture: 'aiAssistant/simple_message_response.json',
|
||||||
|
}).as('chatRequest');
|
||||||
|
|
||||||
|
cy.visit(credentialsPage.url);
|
||||||
|
credentialsPage.getters.emptyListCreateCredentialButton().click();
|
||||||
|
|
||||||
|
credentialsModal.getters.newCredentialModal().should('be.visible');
|
||||||
|
credentialsModal.getters.newCredentialTypeSelect().should('be.visible');
|
||||||
|
credentialsModal.getters.newCredentialTypeOption('Notion API').click();
|
||||||
|
|
||||||
|
credentialsModal.getters.newCredentialTypeButton().click();
|
||||||
|
|
||||||
|
aiAssistant.getters.credentialEditAssistantButton().should('be.visible');
|
||||||
|
aiAssistant.getters.credentialEditAssistantButton().click();
|
||||||
|
cy.wait('@chatRequest');
|
||||||
|
aiAssistant.getters.chatMessagesUser().should('have.length', 1);
|
||||||
|
aiAssistant.getters
|
||||||
|
.chatMessagesUser()
|
||||||
|
.eq(0)
|
||||||
|
.should('contain.text', 'How do I set up the credentials for Notion API?');
|
||||||
|
|
||||||
|
aiAssistant.getters
|
||||||
|
.chatMessagesAssistant()
|
||||||
|
.eq(0)
|
||||||
|
.should('contain.text', 'Hey, this is an assistant message');
|
||||||
|
aiAssistant.getters.credentialEditAssistantButton().should('be.disabled');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
@ -35,6 +35,8 @@ export class AIAssistant extends BasePage {
|
||||||
codeReplacedMessage: () => cy.getByTestId('code-replaced-message'),
|
codeReplacedMessage: () => cy.getByTestId('code-replaced-message'),
|
||||||
nodeErrorViewAssistantButton: () =>
|
nodeErrorViewAssistantButton: () =>
|
||||||
cy.getByTestId('node-error-view-ask-assistant-button').find('button').first(),
|
cy.getByTestId('node-error-view-ask-assistant-button').find('button').first(),
|
||||||
|
credentialEditAssistantButton: () =>
|
||||||
|
cy.getByTestId('credentail-edit-ask-assistant-button').find('button').first(),
|
||||||
};
|
};
|
||||||
|
|
||||||
actions = {
|
actions = {
|
||||||
|
|
|
@ -30,7 +30,7 @@ useHistoryHelper(route);
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
const defaultLocale = computed(() => rootStore.defaultLocale);
|
const defaultLocale = computed(() => rootStore.defaultLocale);
|
||||||
const isDemoMode = computed(() => route.name === VIEWS.DEMO);
|
const isDemoMode = computed(() => route.name === VIEWS.DEMO);
|
||||||
const showAssistantButton = computed(() => assistantStore.canShowAssistantButtons);
|
const showAssistantButton = computed(() => assistantStore.canShowAssistantButtonsOnCanvas);
|
||||||
|
|
||||||
const appGrid = ref<Element | null>(null);
|
const appGrid = ref<Element | null>(null);
|
||||||
|
|
||||||
|
|
|
@ -33,7 +33,7 @@ async function onUserMessage(content: string, quickReplyType?: string, isFeedbac
|
||||||
} else {
|
} else {
|
||||||
await assistantStore.sendMessage({ text: content, quickReplyType });
|
await assistantStore.sendMessage({ text: content, quickReplyType });
|
||||||
}
|
}
|
||||||
const task = assistantStore.isSupportChatSessionInProgress ? 'support' : 'error';
|
const task = assistantStore.chatSessionTask;
|
||||||
const solutionCount = assistantStore.chatMessages.filter(
|
const solutionCount = assistantStore.chatMessages.filter(
|
||||||
(msg) => msg.role === 'assistant' && !['text', 'event'].includes(msg.type),
|
(msg) => msg.role === 'assistant' && !['text', 'event'].includes(msg.type),
|
||||||
).length;
|
).length;
|
||||||
|
|
|
@ -1,16 +1,12 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useI18n } from '@/composables/useI18n';
|
import { useI18n } from '@/composables/useI18n';
|
||||||
import { useTelemetry } from '@/composables/useTelemetry';
|
|
||||||
import { useAssistantStore } from '@/stores/assistant.store';
|
import { useAssistantStore } from '@/stores/assistant.store';
|
||||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
|
||||||
import AssistantAvatar from 'n8n-design-system/components/AskAssistantAvatar/AssistantAvatar.vue';
|
import AssistantAvatar from 'n8n-design-system/components/AskAssistantAvatar/AssistantAvatar.vue';
|
||||||
import AskAssistantButton from 'n8n-design-system/components/AskAssistantButton/AskAssistantButton.vue';
|
import AskAssistantButton from 'n8n-design-system/components/AskAssistantButton/AskAssistantButton.vue';
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
|
|
||||||
const assistantStore = useAssistantStore();
|
const assistantStore = useAssistantStore();
|
||||||
const i18n = useI18n();
|
const i18n = useI18n();
|
||||||
const telemetry = useTelemetry();
|
|
||||||
const workflowStore = useWorkflowsStore();
|
|
||||||
|
|
||||||
const lastUnread = computed(() => {
|
const lastUnread = computed(() => {
|
||||||
const msg = assistantStore.lastUnread;
|
const msg = assistantStore.lastUnread;
|
||||||
|
@ -28,18 +24,17 @@ const lastUnread = computed(() => {
|
||||||
|
|
||||||
const onClick = () => {
|
const onClick = () => {
|
||||||
assistantStore.openChat();
|
assistantStore.openChat();
|
||||||
telemetry.track('User opened assistant', {
|
assistantStore.trackUserOpenedAssistant({
|
||||||
source: 'canvas',
|
source: 'canvas',
|
||||||
task: 'placeholder',
|
task: 'placeholder',
|
||||||
has_existing_session: !assistantStore.isSessionEnded,
|
has_existing_session: !assistantStore.isSessionEnded,
|
||||||
workflow_id: workflowStore.workflowId,
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
v-if="assistantStore.canShowAssistantButtons && !assistantStore.isAssistantOpen"
|
v-if="assistantStore.canShowAssistantButtonsOnCanvas && !assistantStore.isAssistantOpen"
|
||||||
:class="$style.container"
|
:class="$style.container"
|
||||||
data-test-id="ask-assistant-floating-button"
|
data-test-id="ask-assistant-floating-button"
|
||||||
>
|
>
|
||||||
|
|
|
@ -7,19 +7,16 @@ import { useI18n } from '@/composables/useI18n';
|
||||||
import { useUIStore } from '@/stores/ui.store';
|
import { useUIStore } from '@/stores/ui.store';
|
||||||
import type { ChatRequest } from '@/types/assistant.types';
|
import type { ChatRequest } from '@/types/assistant.types';
|
||||||
import { useAssistantStore } from '@/stores/assistant.store';
|
import { useAssistantStore } from '@/stores/assistant.store';
|
||||||
import { useTelemetry } from '@/composables/useTelemetry';
|
import type { ICredentialType } from 'n8n-workflow';
|
||||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
|
||||||
|
|
||||||
const i18n = useI18n();
|
const i18n = useI18n();
|
||||||
const uiStore = useUIStore();
|
const uiStore = useUIStore();
|
||||||
const assistantStore = useAssistantStore();
|
const assistantStore = useAssistantStore();
|
||||||
const workflowsStore = useWorkflowsStore();
|
|
||||||
const telemetry = useTelemetry();
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
name: string;
|
name: string;
|
||||||
data: {
|
data: {
|
||||||
context: ChatRequest.ErrorContext;
|
context: { errorHelp: ChatRequest.ErrorContext } | { credHelp: { credType: ICredentialType } };
|
||||||
};
|
};
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
@ -28,16 +25,16 @@ const close = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const startNewSession = async () => {
|
const startNewSession = async () => {
|
||||||
await assistantStore.initErrorHelper(props.data.context);
|
if ('errorHelp' in props.data.context) {
|
||||||
telemetry.track('User opened assistant', {
|
await assistantStore.initErrorHelper(props.data.context.errorHelp);
|
||||||
|
assistantStore.trackUserOpenedAssistant({
|
||||||
source: 'error',
|
source: 'error',
|
||||||
task: 'error',
|
task: 'error',
|
||||||
has_existing_session: true,
|
has_existing_session: true,
|
||||||
workflow_id: workflowsStore.workflowId,
|
|
||||||
node_type: props.data.context.node.type,
|
|
||||||
error: props.data.context.error,
|
|
||||||
chat_session_id: assistantStore.currentSessionId,
|
|
||||||
});
|
});
|
||||||
|
} else if ('credHelp' in props.data.context) {
|
||||||
|
await assistantStore.initCredHelp(props.data.context.credHelp.credType);
|
||||||
|
}
|
||||||
close();
|
close();
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -18,6 +18,7 @@ import {
|
||||||
CREDENTIAL_DOCS_EXPERIMENT,
|
CREDENTIAL_DOCS_EXPERIMENT,
|
||||||
DOCS_DOMAIN,
|
DOCS_DOMAIN,
|
||||||
EnterpriseEditionFeature,
|
EnterpriseEditionFeature,
|
||||||
|
NEW_ASSISTANT_SESSION_MODAL,
|
||||||
} from '@/constants';
|
} from '@/constants';
|
||||||
import type { PermissionsRecord } from '@/permissions';
|
import type { PermissionsRecord } from '@/permissions';
|
||||||
import { addCredentialTranslation } from '@/plugins/i18n';
|
import { addCredentialTranslation } from '@/plugins/i18n';
|
||||||
|
@ -34,6 +35,8 @@ import OauthButton from './OauthButton.vue';
|
||||||
import CredentialDocs from './CredentialDocs.vue';
|
import CredentialDocs from './CredentialDocs.vue';
|
||||||
import { CREDENTIAL_MARKDOWN_DOCS } from './docs';
|
import { CREDENTIAL_MARKDOWN_DOCS } from './docs';
|
||||||
import { usePostHog } from '@/stores/posthog.store';
|
import { usePostHog } from '@/stores/posthog.store';
|
||||||
|
import { useAssistantStore } from '@/stores/assistant.store';
|
||||||
|
import InlineAskAssistantButton from 'n8n-design-system/components/InlineAskAssistantButton/InlineAskAssistantButton.vue';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
mode: string;
|
mode: string;
|
||||||
|
@ -74,6 +77,7 @@ const ndvStore = useNDVStore();
|
||||||
const rootStore = useRootStore();
|
const rootStore = useRootStore();
|
||||||
const uiStore = useUIStore();
|
const uiStore = useUIStore();
|
||||||
const workflowsStore = useWorkflowsStore();
|
const workflowsStore = useWorkflowsStore();
|
||||||
|
const assistantStore = useAssistantStore();
|
||||||
|
|
||||||
const i18n = useI18n();
|
const i18n = useI18n();
|
||||||
const telemetry = useTelemetry();
|
const telemetry = useTelemetry();
|
||||||
|
@ -167,6 +171,19 @@ const isMissingCredentials = computed(() => props.credentialType === null);
|
||||||
|
|
||||||
const isNewCredential = computed(() => props.mode === 'new' && !props.credentialId);
|
const isNewCredential = computed(() => props.mode === 'new' && !props.credentialId);
|
||||||
|
|
||||||
|
const isAskAssistantAvailable = computed(
|
||||||
|
() =>
|
||||||
|
documentationUrl.value &&
|
||||||
|
documentationUrl.value.includes(DOCS_DOMAIN) &&
|
||||||
|
props.credentialProperties.length &&
|
||||||
|
props.credentialPermissions.update &&
|
||||||
|
assistantStore.isAssistantEnabled,
|
||||||
|
);
|
||||||
|
|
||||||
|
const assistantAlreadyAsked = computed<boolean>(() => {
|
||||||
|
return assistantStore.isCredTypeActive(props.credentialType);
|
||||||
|
});
|
||||||
|
|
||||||
const docs = computed(() => CREDENTIAL_MARKDOWN_DOCS[props.credentialType.name]);
|
const docs = computed(() => CREDENTIAL_MARKDOWN_DOCS[props.credentialType.name]);
|
||||||
const showCredentialDocs = computed(
|
const showCredentialDocs = computed(
|
||||||
() =>
|
() =>
|
||||||
|
@ -191,6 +208,24 @@ function onAuthTypeChange(newType: string): void {
|
||||||
emit('authTypeChanged', newType);
|
emit('authTypeChanged', newType);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function onAskAssistantClick() {
|
||||||
|
const sessionInProgress = !assistantStore.isSessionEnded;
|
||||||
|
if (sessionInProgress) {
|
||||||
|
uiStore.openModalWithData({
|
||||||
|
name: NEW_ASSISTANT_SESSION_MODAL,
|
||||||
|
data: {
|
||||||
|
context: {
|
||||||
|
credHelp: {
|
||||||
|
credType: props.credentialType,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await assistantStore.initCredHelp(props.credentialType);
|
||||||
|
}
|
||||||
|
|
||||||
watch(showOAuthSuccessBanner, (newValue, oldValue) => {
|
watch(showOAuthSuccessBanner, (newValue, oldValue) => {
|
||||||
if (newValue && !oldValue) {
|
if (newValue && !oldValue) {
|
||||||
emit('scrollToTop');
|
emit('scrollToTop');
|
||||||
|
@ -284,6 +319,15 @@ watch(showOAuthSuccessBanner, (newValue, oldValue) => {
|
||||||
@auth-type-changed="onAuthTypeChange"
|
@auth-type-changed="onAuthTypeChange"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="isAskAssistantAvailable"
|
||||||
|
:class="$style.askAssistantButton"
|
||||||
|
data-test-id="credentail-edit-ask-assistant-button"
|
||||||
|
>
|
||||||
|
<InlineAskAssistantButton :asked="assistantAlreadyAsked" @click="onAskAssistantClick" />
|
||||||
|
<span>for setup instructions</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<CopyInput
|
<CopyInput
|
||||||
v-if="isOAuthType && !allOAuth2BasePropertiesOverridden"
|
v-if="isOAuthType && !allOAuth2BasePropertiesOverridden"
|
||||||
:label="$locale.baseText('credentialEdit.credentialConfig.oAuthRedirectUrl')"
|
:label="$locale.baseText('credentialEdit.credentialConfig.oAuthRedirectUrl')"
|
||||||
|
@ -384,4 +428,13 @@ watch(showOAuthSuccessBanner, (newValue, oldValue) => {
|
||||||
.googleReconnectLabel {
|
.googleReconnectLabel {
|
||||||
margin-right: var(--spacing-3xs);
|
margin-right: var(--spacing-3xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.askAssistantButton {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
> span {
|
||||||
|
margin-left: var(--spacing-3xs);
|
||||||
|
font-size: var(--font-size-s);
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -23,8 +23,6 @@ import type { ChatRequest } from '@/types/assistant.types';
|
||||||
import InlineAskAssistantButton from 'n8n-design-system/components/InlineAskAssistantButton/InlineAskAssistantButton.vue';
|
import InlineAskAssistantButton from 'n8n-design-system/components/InlineAskAssistantButton/InlineAskAssistantButton.vue';
|
||||||
import { useUIStore } from '@/stores/ui.store';
|
import { useUIStore } from '@/stores/ui.store';
|
||||||
import { isCommunityPackageName } from '@/utils/nodeTypesUtils';
|
import { isCommunityPackageName } from '@/utils/nodeTypesUtils';
|
||||||
import { useTelemetry } from '@/composables/useTelemetry';
|
|
||||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
// TODO: .node can be undefined
|
// TODO: .node can be undefined
|
||||||
|
@ -41,9 +39,7 @@ const nodeTypesStore = useNodeTypesStore();
|
||||||
const ndvStore = useNDVStore();
|
const ndvStore = useNDVStore();
|
||||||
const rootStore = useRootStore();
|
const rootStore = useRootStore();
|
||||||
const assistantStore = useAssistantStore();
|
const assistantStore = useAssistantStore();
|
||||||
const workflowsStore = useWorkflowsStore();
|
|
||||||
const uiStore = useUIStore();
|
const uiStore = useUIStore();
|
||||||
const telemetry = useTelemetry();
|
|
||||||
|
|
||||||
const displayCause = computed(() => {
|
const displayCause = computed(() => {
|
||||||
return JSON.stringify(props.error.cause ?? '').length < MAX_DISPLAY_DATA_SIZE;
|
return JSON.stringify(props.error.cause ?? '').length < MAX_DISPLAY_DATA_SIZE;
|
||||||
|
@ -123,7 +119,7 @@ const isAskAssistantAvailable = computed(() => {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const isCustomNode = node.value.type === undefined || isCommunityPackageName(node.value.type);
|
const isCustomNode = node.value.type === undefined || isCommunityPackageName(node.value.type);
|
||||||
return assistantStore.canShowAssistantButtons && !isCustomNode;
|
return assistantStore.canShowAssistantButtonsOnCanvas && !isCustomNode;
|
||||||
});
|
});
|
||||||
|
|
||||||
const assistantAlreadyAsked = computed(() => {
|
const assistantAlreadyAsked = computed(() => {
|
||||||
|
@ -411,7 +407,7 @@ function copySuccess() {
|
||||||
async function onAskAssistantClick() {
|
async function onAskAssistantClick() {
|
||||||
const { message, lineNumber, description } = props.error;
|
const { message, lineNumber, description } = props.error;
|
||||||
const sessionInProgress = !assistantStore.isSessionEnded;
|
const sessionInProgress = !assistantStore.isSessionEnded;
|
||||||
const errorPayload: ChatRequest.ErrorContext = {
|
const errorHelp: ChatRequest.ErrorContext = {
|
||||||
error: {
|
error: {
|
||||||
name: props.error.name,
|
name: props.error.name,
|
||||||
message,
|
message,
|
||||||
|
@ -424,18 +420,15 @@ async function onAskAssistantClick() {
|
||||||
if (sessionInProgress) {
|
if (sessionInProgress) {
|
||||||
uiStore.openModalWithData({
|
uiStore.openModalWithData({
|
||||||
name: NEW_ASSISTANT_SESSION_MODAL,
|
name: NEW_ASSISTANT_SESSION_MODAL,
|
||||||
data: { context: errorPayload },
|
data: { context: { errorHelp } },
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await assistantStore.initErrorHelper(errorPayload);
|
await assistantStore.initErrorHelper(errorHelp);
|
||||||
telemetry.track('User opened assistant', {
|
assistantStore.trackUserOpenedAssistant({
|
||||||
source: 'error',
|
source: 'error',
|
||||||
task: 'error',
|
task: 'error',
|
||||||
has_existing_session: false,
|
has_existing_session: false,
|
||||||
workflow_id: workflowsStore.workflowId,
|
|
||||||
node_type: node.value.type,
|
|
||||||
error: props.error,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -271,8 +271,9 @@ describe('AI Assistant store', () => {
|
||||||
|
|
||||||
mockPostHogVariant('control');
|
mockPostHogVariant('control');
|
||||||
setAssistantEnabled(true);
|
setAssistantEnabled(true);
|
||||||
|
expect(assistantStore.isAssistantEnabled).toBe(false);
|
||||||
expect(assistantStore.canShowAssistant).toBe(false);
|
expect(assistantStore.canShowAssistant).toBe(false);
|
||||||
expect(assistantStore.canShowAssistantButtons).toBe(false);
|
expect(assistantStore.canShowAssistantButtonsOnCanvas).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not show assistant if disabled in settings', () => {
|
it('should not show assistant if disabled in settings', () => {
|
||||||
|
@ -280,8 +281,9 @@ describe('AI Assistant store', () => {
|
||||||
|
|
||||||
mockPostHogVariant('variant');
|
mockPostHogVariant('variant');
|
||||||
setAssistantEnabled(false);
|
setAssistantEnabled(false);
|
||||||
|
expect(assistantStore.isAssistantEnabled).toBe(false);
|
||||||
expect(assistantStore.canShowAssistant).toBe(false);
|
expect(assistantStore.canShowAssistant).toBe(false);
|
||||||
expect(assistantStore.canShowAssistantButtons).toBe(false);
|
expect(assistantStore.canShowAssistantButtonsOnCanvas).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should show assistant if all conditions are met', () => {
|
it('should show assistant if all conditions are met', () => {
|
||||||
|
@ -289,8 +291,9 @@ describe('AI Assistant store', () => {
|
||||||
|
|
||||||
setAssistantEnabled(true);
|
setAssistantEnabled(true);
|
||||||
mockPostHogVariant('variant');
|
mockPostHogVariant('variant');
|
||||||
|
expect(assistantStore.isAssistantEnabled).toBe(true);
|
||||||
expect(assistantStore.canShowAssistant).toBe(true);
|
expect(assistantStore.canShowAssistant).toBe(true);
|
||||||
expect(assistantStore.canShowAssistantButtons).toBe(true);
|
expect(assistantStore.canShowAssistantButtonsOnCanvas).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should initialize assistant chat session on node error', async () => {
|
it('should initialize assistant chat session on node error', async () => {
|
||||||
|
|
|
@ -16,7 +16,7 @@ import { useRoute } from 'vue-router';
|
||||||
import { useSettingsStore } from './settings.store';
|
import { useSettingsStore } from './settings.store';
|
||||||
import { assert } from '@/utils/assert';
|
import { assert } from '@/utils/assert';
|
||||||
import { useWorkflowsStore } from './workflows.store';
|
import { useWorkflowsStore } from './workflows.store';
|
||||||
import type { INodeParameters } from 'n8n-workflow';
|
import type { ICredentialType, INodeParameters } from 'n8n-workflow';
|
||||||
import { deepCopy } from 'n8n-workflow';
|
import { deepCopy } from 'n8n-workflow';
|
||||||
import { ndvEventBus, codeNodeEditorEventBus } from '@/event-bus';
|
import { ndvEventBus, codeNodeEditorEventBus } from '@/event-bus';
|
||||||
import { useNDVStore } from './ndv.store';
|
import { useNDVStore } from './ndv.store';
|
||||||
|
@ -39,7 +39,12 @@ import AiUpdatedCodeMessage from '@/components/AiUpdatedCodeMessage.vue';
|
||||||
export const MAX_CHAT_WIDTH = 425;
|
export const MAX_CHAT_WIDTH = 425;
|
||||||
export const MIN_CHAT_WIDTH = 250;
|
export const MIN_CHAT_WIDTH = 250;
|
||||||
export const DEFAULT_CHAT_WIDTH = 330;
|
export const DEFAULT_CHAT_WIDTH = 330;
|
||||||
export const ENABLED_VIEWS = [...EDITABLE_CANVAS_VIEWS, VIEWS.EXECUTION_PREVIEW];
|
export const ENABLED_VIEWS = [
|
||||||
|
...EDITABLE_CANVAS_VIEWS,
|
||||||
|
VIEWS.EXECUTION_PREVIEW,
|
||||||
|
VIEWS.WORKFLOWS,
|
||||||
|
VIEWS.CREDENTIALS,
|
||||||
|
];
|
||||||
const READABLE_TYPES = ['code-diff', 'text', 'block'];
|
const READABLE_TYPES = ['code-diff', 'text', 'block'];
|
||||||
|
|
||||||
export const useAssistantStore = defineStore(STORES.ASSISTANT, () => {
|
export const useAssistantStore = defineStore(STORES.ASSISTANT, () => {
|
||||||
|
@ -65,6 +70,8 @@ export const useAssistantStore = defineStore(STORES.ASSISTANT, () => {
|
||||||
suggested: INodeParameters;
|
suggested: INodeParameters;
|
||||||
};
|
};
|
||||||
}>({});
|
}>({});
|
||||||
|
|
||||||
|
const chatSessionCredType = ref<ICredentialType | undefined>();
|
||||||
const chatSessionError = ref<ChatRequest.ErrorContext | undefined>();
|
const chatSessionError = ref<ChatRequest.ErrorContext | undefined>();
|
||||||
const currentSessionId = ref<string | undefined>();
|
const currentSessionId = ref<string | undefined>();
|
||||||
const currentSessionActiveExecutionId = ref<string | undefined>();
|
const currentSessionActiveExecutionId = ref<string | undefined>();
|
||||||
|
@ -74,18 +81,12 @@ export const useAssistantStore = defineStore(STORES.ASSISTANT, () => {
|
||||||
// This is used to show a message when the assistant is performing intermediate steps
|
// 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
|
// We use streaming for assistants that support it, and this for agents
|
||||||
const assistantThinkingMessage = ref<string | undefined>();
|
const assistantThinkingMessage = ref<string | undefined>();
|
||||||
|
const chatSessionTask = ref<'error' | 'support' | 'credentials' | undefined>();
|
||||||
|
|
||||||
const isExperimentEnabled = computed(
|
const isExperimentEnabled = computed(
|
||||||
() => getVariant(AI_ASSISTANT_EXPERIMENT.name) === AI_ASSISTANT_EXPERIMENT.variant,
|
() => getVariant(AI_ASSISTANT_EXPERIMENT.name) === AI_ASSISTANT_EXPERIMENT.variant,
|
||||||
);
|
);
|
||||||
|
|
||||||
const canShowAssistant = computed(
|
|
||||||
() =>
|
|
||||||
isExperimentEnabled.value &&
|
|
||||||
settings.isAiAssistantEnabled &&
|
|
||||||
ENABLED_VIEWS.includes(route.name as VIEWS),
|
|
||||||
);
|
|
||||||
|
|
||||||
const assistantMessages = computed(() =>
|
const assistantMessages = computed(() =>
|
||||||
chatMessages.value.filter((msg) => msg.role === 'assistant'),
|
chatMessages.value.filter((msg) => msg.role === 'assistant'),
|
||||||
);
|
);
|
||||||
|
@ -103,11 +104,16 @@ export const useAssistantStore = defineStore(STORES.ASSISTANT, () => {
|
||||||
|
|
||||||
const isAssistantOpen = computed(() => canShowAssistant.value && chatWindowOpen.value);
|
const isAssistantOpen = computed(() => canShowAssistant.value && chatWindowOpen.value);
|
||||||
|
|
||||||
const canShowAssistantButtons = computed(
|
const isAssistantEnabled = computed(
|
||||||
() =>
|
() => isExperimentEnabled.value && settings.isAiAssistantEnabled,
|
||||||
isExperimentEnabled.value &&
|
);
|
||||||
settings.isAiAssistantEnabled &&
|
|
||||||
EDITABLE_CANVAS_VIEWS.includes(route.name as VIEWS),
|
const canShowAssistant = computed(
|
||||||
|
() => isAssistantEnabled.value && ENABLED_VIEWS.includes(route.name as VIEWS),
|
||||||
|
);
|
||||||
|
|
||||||
|
const canShowAssistantButtonsOnCanvas = computed(
|
||||||
|
() => isAssistantEnabled.value && EDITABLE_CANVAS_VIEWS.includes(route.name as VIEWS),
|
||||||
);
|
);
|
||||||
|
|
||||||
const unreadCount = computed(
|
const unreadCount = computed(
|
||||||
|
@ -117,10 +123,6 @@ export const useAssistantStore = defineStore(STORES.ASSISTANT, () => {
|
||||||
).length,
|
).length,
|
||||||
);
|
);
|
||||||
|
|
||||||
const isSupportChatSessionInProgress = computed(() => {
|
|
||||||
return currentSessionId.value !== undefined && chatSessionError.value === undefined;
|
|
||||||
});
|
|
||||||
|
|
||||||
watch(route, () => {
|
watch(route, () => {
|
||||||
const activeWorkflowId = workflowsStore.workflowId;
|
const activeWorkflowId = workflowsStore.workflowId;
|
||||||
if (
|
if (
|
||||||
|
@ -141,6 +143,9 @@ export const useAssistantStore = defineStore(STORES.ASSISTANT, () => {
|
||||||
currentSessionActiveExecutionId.value = undefined;
|
currentSessionActiveExecutionId.value = undefined;
|
||||||
suggestions.value = {};
|
suggestions.value = {};
|
||||||
nodeExecutionStatus.value = 'not_executed';
|
nodeExecutionStatus.value = 'not_executed';
|
||||||
|
chatSessionCredType.value = undefined;
|
||||||
|
chatSessionTask.value = undefined;
|
||||||
|
currentSessionWorkflowId.value = workflowsStore.workflowId;
|
||||||
}
|
}
|
||||||
|
|
||||||
// As assistant sidebar opens and closes, use window width to calculate the container width
|
// As assistant sidebar opens and closes, use window width to calculate the container width
|
||||||
|
@ -169,7 +174,6 @@ export const useAssistantStore = defineStore(STORES.ASSISTANT, () => {
|
||||||
(msg) => !(msg.id === id && msg.role === 'assistant'),
|
(msg) => !(msg.id === id && msg.role === 'assistant'),
|
||||||
);
|
);
|
||||||
assistantThinkingMessage.value = undefined;
|
assistantThinkingMessage.value = undefined;
|
||||||
// TODO: simplify
|
|
||||||
newMessages.forEach((msg) => {
|
newMessages.forEach((msg) => {
|
||||||
if (msg.type === 'message') {
|
if (msg.type === 'message') {
|
||||||
messages.push({
|
messages.push({
|
||||||
|
@ -235,11 +239,18 @@ export const useAssistantStore = defineStore(STORES.ASSISTANT, () => {
|
||||||
const targetNode = context.node.name;
|
const targetNode = context.node.name;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
chatSessionTask.value === 'error' &&
|
||||||
workflowsStore.activeExecutionId === currentSessionActiveExecutionId.value &&
|
workflowsStore.activeExecutionId === currentSessionActiveExecutionId.value &&
|
||||||
targetNode === chatSessionError.value?.node.name
|
targetNode === chatSessionError.value?.node.name
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isCredTypeActive(credType: ICredentialType) {
|
||||||
|
return (
|
||||||
|
chatSessionTask.value === 'credentials' && credType.name === chatSessionCredType.value?.name
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function clearMessages() {
|
function clearMessages() {
|
||||||
chatMessages.value = [];
|
chatMessages.value = [];
|
||||||
}
|
}
|
||||||
|
@ -286,13 +297,18 @@ export const useAssistantStore = defineStore(STORES.ASSISTANT, () => {
|
||||||
'Assistant session started',
|
'Assistant session started',
|
||||||
{
|
{
|
||||||
chat_session_id: currentSessionId.value,
|
chat_session_id: currentSessionId.value,
|
||||||
task: isSupportChatSessionInProgress.value ? 'support' : 'error',
|
task: chatSessionTask.value,
|
||||||
node_type: chatSessionError.value?.node.type,
|
node_type: chatSessionError.value?.node.type,
|
||||||
|
credential_type: chatSessionCredType.value?.name,
|
||||||
},
|
},
|
||||||
{ withPostHog: true },
|
{ withPostHog: true },
|
||||||
);
|
);
|
||||||
// Track first user message in support chat now that we have a session id
|
// Track first user message in support chat now that we have a session id
|
||||||
if (usersMessages.value.length === 1 && isSupportChatSessionInProgress.value) {
|
if (
|
||||||
|
usersMessages.value.length === 1 &&
|
||||||
|
!currentSessionId.value &&
|
||||||
|
chatSessionTask.value === 'support'
|
||||||
|
) {
|
||||||
const firstUserMessage = usersMessages.value[0] as ChatUI.TextMessage;
|
const firstUserMessage = usersMessages.value[0] as ChatUI.TextMessage;
|
||||||
trackUserMessage(firstUserMessage.content, false);
|
trackUserMessage(firstUserMessage.content, false);
|
||||||
}
|
}
|
||||||
|
@ -319,26 +335,53 @@ export const useAssistantStore = defineStore(STORES.ASSISTANT, () => {
|
||||||
}, 4000);
|
}, 4000);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function initSupportChat(userMessage: string) {
|
async function initCredHelp(credType: ICredentialType) {
|
||||||
|
const hasExistingSession = !!currentSessionId.value;
|
||||||
|
const credentialName = credType.displayName;
|
||||||
|
const question = `How do I set up the credentials for ${credentialName}?`;
|
||||||
|
|
||||||
|
await initSupportChat(question, credType);
|
||||||
|
|
||||||
|
trackUserOpenedAssistant({
|
||||||
|
source: 'credential',
|
||||||
|
task: 'credentials',
|
||||||
|
has_existing_session: hasExistingSession,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initSupportChat(userMessage: string, credentialType?: ICredentialType) {
|
||||||
const id = getRandomId();
|
const id = getRandomId();
|
||||||
resetAssistantChat();
|
resetAssistantChat();
|
||||||
chatSessionError.value = undefined;
|
chatSessionTask.value = credentialType ? 'credentials' : 'support';
|
||||||
currentSessionActiveExecutionId.value = undefined;
|
chatSessionCredType.value = credentialType;
|
||||||
currentSessionWorkflowId.value = workflowsStore.workflowId;
|
chatWindowOpen.value = true;
|
||||||
addUserMessage(userMessage, id);
|
addUserMessage(userMessage, id);
|
||||||
addLoadingAssistantMessage(locale.baseText('aiAssistant.thinkingSteps.thinking'));
|
addLoadingAssistantMessage(locale.baseText('aiAssistant.thinkingSteps.thinking'));
|
||||||
streaming.value = true;
|
streaming.value = true;
|
||||||
chatWithAssistant(
|
|
||||||
rootStore.restApiContext,
|
let payload: ChatRequest.InitSupportChat | ChatRequest.InitCredHelp = {
|
||||||
{
|
|
||||||
payload: {
|
|
||||||
role: 'user',
|
role: 'user',
|
||||||
type: 'init-support-chat',
|
type: 'init-support-chat',
|
||||||
user: {
|
user: {
|
||||||
firstName: usersStore.currentUser?.firstName ?? '',
|
firstName: usersStore.currentUser?.firstName ?? '',
|
||||||
},
|
},
|
||||||
question: userMessage,
|
question: userMessage,
|
||||||
|
};
|
||||||
|
if (credentialType) {
|
||||||
|
payload = {
|
||||||
|
...payload,
|
||||||
|
type: 'init-cred-help',
|
||||||
|
credentialType: {
|
||||||
|
name: credentialType.name,
|
||||||
|
displayName: credentialType.displayName,
|
||||||
},
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
chatWithAssistant(
|
||||||
|
rootStore.restApiContext,
|
||||||
|
{
|
||||||
|
payload,
|
||||||
},
|
},
|
||||||
(msg) => onEachStreamingMessage(msg, id),
|
(msg) => onEachStreamingMessage(msg, id),
|
||||||
() => onDoneStreaming(id),
|
() => onDoneStreaming(id),
|
||||||
|
@ -356,6 +399,7 @@ export const useAssistantStore = defineStore(STORES.ASSISTANT, () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
resetAssistantChat();
|
resetAssistantChat();
|
||||||
|
chatSessionTask.value = 'error';
|
||||||
chatSessionError.value = context;
|
chatSessionError.value = context;
|
||||||
currentSessionWorkflowId.value = workflowsStore.workflowId;
|
currentSessionWorkflowId.value = workflowsStore.workflowId;
|
||||||
|
|
||||||
|
@ -437,7 +481,7 @@ export const useAssistantStore = defineStore(STORES.ASSISTANT, () => {
|
||||||
await sendEvent('node-execution-errored', pushEvent.data.error);
|
await sendEvent('node-execution-errored', pushEvent.data.error);
|
||||||
nodeExecutionStatus.value = 'error';
|
nodeExecutionStatus.value = 'error';
|
||||||
telemetry.track('User executed node after assistant suggestion', {
|
telemetry.track('User executed node after assistant suggestion', {
|
||||||
task: 'error',
|
task: chatSessionTask.value,
|
||||||
chat_session_id: currentSessionId.value,
|
chat_session_id: currentSessionId.value,
|
||||||
success: false,
|
success: false,
|
||||||
});
|
});
|
||||||
|
@ -448,7 +492,7 @@ export const useAssistantStore = defineStore(STORES.ASSISTANT, () => {
|
||||||
await sendEvent('node-execution-succeeded');
|
await sendEvent('node-execution-succeeded');
|
||||||
nodeExecutionStatus.value = 'success';
|
nodeExecutionStatus.value = 'success';
|
||||||
telemetry.track('User executed node after assistant suggestion', {
|
telemetry.track('User executed node after assistant suggestion', {
|
||||||
task: 'error',
|
task: chatSessionTask.value,
|
||||||
chat_session_id: currentSessionId.value,
|
chat_session_id: currentSessionId.value,
|
||||||
success: true,
|
success: true,
|
||||||
});
|
});
|
||||||
|
@ -506,7 +550,36 @@ export const useAssistantStore = defineStore(STORES.ASSISTANT, () => {
|
||||||
is_quick_reply: isQuickReply,
|
is_quick_reply: isQuickReply,
|
||||||
chat_session_id: currentSessionId.value,
|
chat_session_id: currentSessionId.value,
|
||||||
message_number: usersMessages.value.length,
|
message_number: usersMessages.value.length,
|
||||||
task: isSupportChatSessionInProgress.value ? 'support' : 'error',
|
task: chatSessionTask.value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function trackUserOpenedAssistant({
|
||||||
|
source,
|
||||||
|
task,
|
||||||
|
has_existing_session,
|
||||||
|
}: { has_existing_session: boolean } & (
|
||||||
|
| {
|
||||||
|
source: 'error';
|
||||||
|
task: 'error';
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
source: 'canvas';
|
||||||
|
task: 'placeholder';
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
source: 'credential';
|
||||||
|
task: 'credentials';
|
||||||
|
}
|
||||||
|
)) {
|
||||||
|
telemetry.track('User opened assistant', {
|
||||||
|
source,
|
||||||
|
task,
|
||||||
|
has_existing_session,
|
||||||
|
workflow_id: workflowsStore.workflowId,
|
||||||
|
node_type: chatSessionError.value?.node?.type,
|
||||||
|
error: chatSessionError.value?.error,
|
||||||
|
chat_session_id: currentSessionId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -632,17 +705,19 @@ export const useAssistantStore = defineStore(STORES.ASSISTANT, () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
isAssistantEnabled,
|
||||||
|
canShowAssistantButtonsOnCanvas,
|
||||||
chatWidth,
|
chatWidth,
|
||||||
chatMessages,
|
chatMessages,
|
||||||
unreadCount,
|
unreadCount,
|
||||||
streaming,
|
streaming,
|
||||||
isAssistantOpen,
|
isAssistantOpen,
|
||||||
canShowAssistant,
|
canShowAssistant,
|
||||||
canShowAssistantButtons,
|
|
||||||
currentSessionId,
|
currentSessionId,
|
||||||
lastUnread,
|
lastUnread,
|
||||||
isSessionEnded,
|
isSessionEnded,
|
||||||
onNodeExecution,
|
onNodeExecution,
|
||||||
|
trackUserOpenedAssistant,
|
||||||
closeChat,
|
closeChat,
|
||||||
openChat,
|
openChat,
|
||||||
updateWindowWidth,
|
updateWindowWidth,
|
||||||
|
@ -657,6 +732,8 @@ export const useAssistantStore = defineStore(STORES.ASSISTANT, () => {
|
||||||
addAssistantMessages,
|
addAssistantMessages,
|
||||||
assistantThinkingMessage,
|
assistantThinkingMessage,
|
||||||
chatSessionError,
|
chatSessionError,
|
||||||
isSupportChatSessionInProgress,
|
chatSessionTask,
|
||||||
|
initCredHelp,
|
||||||
|
isCredTypeActive,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
|
@ -41,6 +41,19 @@ export namespace ChatRequest {
|
||||||
question: string;
|
question: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface InitCredHelp {
|
||||||
|
role: 'user';
|
||||||
|
type: 'init-cred-help';
|
||||||
|
user: {
|
||||||
|
firstName: string;
|
||||||
|
};
|
||||||
|
question: string;
|
||||||
|
credentialType: {
|
||||||
|
name: string;
|
||||||
|
displayName: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export type InteractionEventName = 'node-execution-succeeded' | 'node-execution-errored';
|
export type InteractionEventName = 'node-execution-succeeded' | 'node-execution-errored';
|
||||||
|
|
||||||
interface EventRequestPayload {
|
interface EventRequestPayload {
|
||||||
|
@ -59,7 +72,7 @@ export namespace ChatRequest {
|
||||||
|
|
||||||
export type RequestPayload =
|
export type RequestPayload =
|
||||||
| {
|
| {
|
||||||
payload: InitErrorHelper | InitSupportChat;
|
payload: InitErrorHelper | InitSupportChat | InitCredHelp;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
payload: EventRequestPayload | UserChatMessage;
|
payload: EventRequestPayload | UserChatMessage;
|
||||||
|
|
Loading…
Reference in a new issue