feat: Add credential help to Assistant (no-changelog) (#10736)

This commit is contained in:
Mutasem Aldmour 2024-09-11 16:38:39 +02:00 committed by GitHub
parent 50459bacab
commit 2bc983ba32
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 284 additions and 80 deletions

View file

@ -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');
});
});

View file

@ -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 = {

View file

@ -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);

View file

@ -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;

View file

@ -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"
> >

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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 () => {

View file

@ -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,
}; };
}); });

View file

@ -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;