Skip to content

Commit

Permalink
feat: Add credential help to Assistant (no-changelog) (#10736)
Browse files Browse the repository at this point in the history
  • Loading branch information
mutdmour authored Sep 11, 2024
1 parent 50459ba commit 2bc983b
Show file tree
Hide file tree
Showing 11 changed files with 284 additions and 80 deletions.
75 changes: 73 additions & 2 deletions cypress/e2e/45-ai-assistant.cy.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import { SCHEDULE_TRIGGER_NODE_NAME } from '../constants';
import { NDV, WorkflowPage } from '../pages';
import { clickCreateNewCredential, openCredentialSelect } from '../composables/ndv';
import { GMAIL_NODE_NAME, SCHEDULE_TRIGGER_NODE_NAME } from '../constants';
import { CredentialsModal, CredentialsPage, NDV, WorkflowPage } from '../pages';
import { AIAssistant } from '../pages/features/ai-assistant';

const wf = new WorkflowPage();
const ndv = new NDV();
const aiAssistant = new AIAssistant();
const credentialsPage = new CredentialsPage();
const credentialsModal = new CredentialsModal();

describe('AI Assistant::disabled', () => {
beforeEach(() => {
Expand Down Expand Up @@ -303,3 +306,71 @@ describe('AI Assistant::enabled', () => {
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');
});
});
2 changes: 2 additions & 0 deletions cypress/pages/features/ai-assistant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ export class AIAssistant extends BasePage {
codeReplacedMessage: () => cy.getByTestId('code-replaced-message'),
nodeErrorViewAssistantButton: () =>
cy.getByTestId('node-error-view-ask-assistant-button').find('button').first(),
credentialEditAssistantButton: () =>
cy.getByTestId('credentail-edit-ask-assistant-button').find('button').first(),
};

actions = {
Expand Down
2 changes: 1 addition & 1 deletion packages/editor-ui/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ useHistoryHelper(route);
const loading = ref(true);
const defaultLocale = computed(() => rootStore.defaultLocale);
const isDemoMode = computed(() => route.name === VIEWS.DEMO);
const showAssistantButton = computed(() => assistantStore.canShowAssistantButtons);
const showAssistantButton = computed(() => assistantStore.canShowAssistantButtonsOnCanvas);
const appGrid = ref<Element | null>(null);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ async function onUserMessage(content: string, quickReplyType?: string, isFeedbac
} else {
await assistantStore.sendMessage({ text: content, quickReplyType });
}
const task = assistantStore.isSupportChatSessionInProgress ? 'support' : 'error';
const task = assistantStore.chatSessionTask;
const solutionCount = assistantStore.chatMessages.filter(
(msg) => msg.role === 'assistant' && !['text', 'event'].includes(msg.type),
).length;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,12 @@
<script setup lang="ts">
import { useI18n } from '@/composables/useI18n';
import { useTelemetry } from '@/composables/useTelemetry';
import { useAssistantStore } from '@/stores/assistant.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import AssistantAvatar from 'n8n-design-system/components/AskAssistantAvatar/AssistantAvatar.vue';
import AskAssistantButton from 'n8n-design-system/components/AskAssistantButton/AskAssistantButton.vue';
import { computed } from 'vue';
const assistantStore = useAssistantStore();
const i18n = useI18n();
const telemetry = useTelemetry();
const workflowStore = useWorkflowsStore();
const lastUnread = computed(() => {
const msg = assistantStore.lastUnread;
Expand All @@ -28,18 +24,17 @@ const lastUnread = computed(() => {
const onClick = () => {
assistantStore.openChat();
telemetry.track('User opened assistant', {
assistantStore.trackUserOpenedAssistant({
source: 'canvas',
task: 'placeholder',
has_existing_session: !assistantStore.isSessionEnded,
workflow_id: workflowStore.workflowId,
});
};
</script>

<template>
<div
v-if="assistantStore.canShowAssistantButtons && !assistantStore.isAssistantOpen"
v-if="assistantStore.canShowAssistantButtonsOnCanvas && !assistantStore.isAssistantOpen"
:class="$style.container"
data-test-id="ask-assistant-floating-button"
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,16 @@ import { useI18n } from '@/composables/useI18n';
import { useUIStore } from '@/stores/ui.store';
import type { ChatRequest } from '@/types/assistant.types';
import { useAssistantStore } from '@/stores/assistant.store';
import { useTelemetry } from '@/composables/useTelemetry';
import { useWorkflowsStore } from '@/stores/workflows.store';
import type { ICredentialType } from 'n8n-workflow';
const i18n = useI18n();
const uiStore = useUIStore();
const assistantStore = useAssistantStore();
const workflowsStore = useWorkflowsStore();
const telemetry = useTelemetry();
const props = defineProps<{
name: string;
data: {
context: ChatRequest.ErrorContext;
context: { errorHelp: ChatRequest.ErrorContext } | { credHelp: { credType: ICredentialType } };
};
}>();
Expand All @@ -28,16 +25,16 @@ const close = () => {
};
const startNewSession = async () => {
await assistantStore.initErrorHelper(props.data.context);
telemetry.track('User opened assistant', {
source: 'error',
task: 'error',
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,
});
if ('errorHelp' in props.data.context) {
await assistantStore.initErrorHelper(props.data.context.errorHelp);
assistantStore.trackUserOpenedAssistant({
source: 'error',
task: 'error',
has_existing_session: true,
});
} else if ('credHelp' in props.data.context) {
await assistantStore.initCredHelp(props.data.context.credHelp.credType);
}
close();
};
</script>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
CREDENTIAL_DOCS_EXPERIMENT,
DOCS_DOMAIN,
EnterpriseEditionFeature,
NEW_ASSISTANT_SESSION_MODAL,
} from '@/constants';
import type { PermissionsRecord } from '@/permissions';
import { addCredentialTranslation } from '@/plugins/i18n';
Expand All @@ -34,6 +35,8 @@ import OauthButton from './OauthButton.vue';
import CredentialDocs from './CredentialDocs.vue';
import { CREDENTIAL_MARKDOWN_DOCS } from './docs';
import { usePostHog } from '@/stores/posthog.store';
import { useAssistantStore } from '@/stores/assistant.store';
import InlineAskAssistantButton from 'n8n-design-system/components/InlineAskAssistantButton/InlineAskAssistantButton.vue';
type Props = {
mode: string;
Expand Down Expand Up @@ -74,6 +77,7 @@ const ndvStore = useNDVStore();
const rootStore = useRootStore();
const uiStore = useUIStore();
const workflowsStore = useWorkflowsStore();
const assistantStore = useAssistantStore();
const i18n = useI18n();
const telemetry = useTelemetry();
Expand Down Expand Up @@ -167,6 +171,19 @@ const isMissingCredentials = computed(() => props.credentialType === null);
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 showCredentialDocs = computed(
() =>
Expand All @@ -191,6 +208,24 @@ function onAuthTypeChange(newType: string): void {
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) => {
if (newValue && !oldValue) {
emit('scrollToTop');
Expand Down Expand Up @@ -284,6 +319,15 @@ watch(showOAuthSuccessBanner, (newValue, oldValue) => {
@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
v-if="isOAuthType && !allOAuth2BasePropertiesOverridden"
:label="$locale.baseText('credentialEdit.credentialConfig.oAuthRedirectUrl')"
Expand Down Expand Up @@ -384,4 +428,13 @@ watch(showOAuthSuccessBanner, (newValue, oldValue) => {
.googleReconnectLabel {
margin-right: var(--spacing-3xs);
}
.askAssistantButton {
display: flex;
align-items: center;
> span {
margin-left: var(--spacing-3xs);
font-size: var(--font-size-s);
}
}
</style>
17 changes: 5 additions & 12 deletions packages/editor-ui/src/components/Error/NodeErrorView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,6 @@ import type { ChatRequest } from '@/types/assistant.types';
import InlineAskAssistantButton from 'n8n-design-system/components/InlineAskAssistantButton/InlineAskAssistantButton.vue';
import { useUIStore } from '@/stores/ui.store';
import { isCommunityPackageName } from '@/utils/nodeTypesUtils';
import { useTelemetry } from '@/composables/useTelemetry';
import { useWorkflowsStore } from '@/stores/workflows.store';
type Props = {
// TODO: .node can be undefined
Expand All @@ -41,9 +39,7 @@ const nodeTypesStore = useNodeTypesStore();
const ndvStore = useNDVStore();
const rootStore = useRootStore();
const assistantStore = useAssistantStore();
const workflowsStore = useWorkflowsStore();
const uiStore = useUIStore();
const telemetry = useTelemetry();
const displayCause = computed(() => {
return JSON.stringify(props.error.cause ?? '').length < MAX_DISPLAY_DATA_SIZE;
Expand Down Expand Up @@ -123,7 +119,7 @@ const isAskAssistantAvailable = computed(() => {
return false;
}
const isCustomNode = node.value.type === undefined || isCommunityPackageName(node.value.type);
return assistantStore.canShowAssistantButtons && !isCustomNode;
return assistantStore.canShowAssistantButtonsOnCanvas && !isCustomNode;
});
const assistantAlreadyAsked = computed(() => {
Expand Down Expand Up @@ -411,7 +407,7 @@ function copySuccess() {
async function onAskAssistantClick() {
const { message, lineNumber, description } = props.error;
const sessionInProgress = !assistantStore.isSessionEnded;
const errorPayload: ChatRequest.ErrorContext = {
const errorHelp: ChatRequest.ErrorContext = {
error: {
name: props.error.name,
message,
Expand All @@ -424,18 +420,15 @@ async function onAskAssistantClick() {
if (sessionInProgress) {
uiStore.openModalWithData({
name: NEW_ASSISTANT_SESSION_MODAL,
data: { context: errorPayload },
data: { context: { errorHelp } },
});
return;
}
await assistantStore.initErrorHelper(errorPayload);
telemetry.track('User opened assistant', {
await assistantStore.initErrorHelper(errorHelp);
assistantStore.trackUserOpenedAssistant({
source: 'error',
task: 'error',
has_existing_session: false,
workflow_id: workflowsStore.workflowId,
node_type: node.value.type,
error: props.error,
});
}
</script>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -271,26 +271,29 @@ describe('AI Assistant store', () => {

mockPostHogVariant('control');
setAssistantEnabled(true);
expect(assistantStore.isAssistantEnabled).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', () => {
const assistantStore = useAssistantStore();

mockPostHogVariant('variant');
setAssistantEnabled(false);
expect(assistantStore.isAssistantEnabled).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', () => {
const assistantStore = useAssistantStore();

setAssistantEnabled(true);
mockPostHogVariant('variant');
expect(assistantStore.isAssistantEnabled).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 () => {
Expand Down
Loading

0 comments on commit 2bc983b

Please sign in to comment.