mirror of
https://github.com/n8n-io/n8n.git
synced 2025-03-05 20:50:17 -08:00
Merge 3d64809abc
into d2dd1796a8
This commit is contained in:
commit
5bb7383e82
|
@ -1,91 +1,36 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Ref } from 'vue';
|
import { computed, ref, watchEffect } from 'vue';
|
||||||
import { provide, watch, computed, ref, watchEffect } from 'vue';
|
|
||||||
import { ChatOptionsSymbol, ChatSymbol } from '@n8n/chat/constants';
|
|
||||||
import type { Router } from 'vue-router';
|
|
||||||
import { useRouter } from 'vue-router';
|
|
||||||
import { chatEventBus } from '@n8n/chat/event-buses';
|
|
||||||
import { VIEWS } from '@/constants';
|
|
||||||
import { v4 as uuid } from 'uuid';
|
|
||||||
|
|
||||||
// Components
|
// Components
|
||||||
import ChatMessagesPanel from './components/ChatMessagesPanel.vue';
|
import ChatMessagesPanel from './components/ChatMessagesPanel.vue';
|
||||||
import ChatLogsPanel from './components/ChatLogsPanel.vue';
|
import ChatLogsPanel from './components/ChatLogsPanel.vue';
|
||||||
|
|
||||||
// Composables
|
// Composables
|
||||||
import { useChatTrigger } from './composables/useChatTrigger';
|
|
||||||
import { useChatMessaging } from './composables/useChatMessaging';
|
|
||||||
import { useResize } from './composables/useResize';
|
import { useResize } from './composables/useResize';
|
||||||
import { useI18n } from '@/composables/useI18n';
|
|
||||||
import { useNodeHelpers } from '@/composables/useNodeHelpers';
|
|
||||||
import { useRunWorkflow } from '@/composables/useRunWorkflow';
|
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
import type { Chat, ChatMessage, ChatOptions } from '@n8n/chat/types';
|
|
||||||
import type { RunWorkflowChatPayload } from './composables/useChatMessaging';
|
|
||||||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
|
||||||
import { useCanvasStore } from '@/stores/canvas.store';
|
import { useCanvasStore } from '@/stores/canvas.store';
|
||||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||||
|
import { useChatState } from '@/components/CanvasChat/composables/useChatState';
|
||||||
|
|
||||||
const workflowsStore = useWorkflowsStore();
|
const workflowsStore = useWorkflowsStore();
|
||||||
const canvasStore = useCanvasStore();
|
const canvasStore = useCanvasStore();
|
||||||
const nodeTypesStore = useNodeTypesStore();
|
|
||||||
const nodeHelpers = useNodeHelpers();
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
// Component state
|
// Component state
|
||||||
const messages = ref<ChatMessage[]>([]);
|
|
||||||
const currentSessionId = ref<string>(uuid().replace(/-/g, ''));
|
|
||||||
const isDisabled = ref(false);
|
const isDisabled = ref(false);
|
||||||
const container = ref<HTMLElement>();
|
const container = ref<HTMLElement>();
|
||||||
|
|
||||||
// Computed properties
|
// Computed properties
|
||||||
const workflow = computed(() => workflowsStore.getCurrentWorkflow());
|
const workflow = computed(() => workflowsStore.getCurrentWorkflow());
|
||||||
|
|
||||||
const allConnections = computed(() => workflowsStore.allConnections);
|
|
||||||
const isChatOpen = computed(() => {
|
const isChatOpen = computed(() => {
|
||||||
const result = workflowsStore.isChatPanelOpen;
|
const result = workflowsStore.isChatPanelOpen;
|
||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
const canvasNodes = computed(() => workflowsStore.allNodes);
|
|
||||||
const isLogsOpen = computed(() => workflowsStore.isLogsPanelOpen);
|
const isLogsOpen = computed(() => workflowsStore.isLogsPanelOpen);
|
||||||
const previousChatMessages = computed(() => workflowsStore.getPastChatMessages);
|
const previousChatMessages = computed(() => workflowsStore.getPastChatMessages);
|
||||||
const resultData = computed(() => workflowsStore.getWorkflowRunData);
|
const resultData = computed(() => workflowsStore.getWorkflowRunData);
|
||||||
// Expose internal state for testing
|
|
||||||
defineExpose({
|
|
||||||
messages,
|
|
||||||
currentSessionId,
|
|
||||||
isDisabled,
|
|
||||||
workflow,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { runWorkflow } = useRunWorkflow({ router });
|
|
||||||
|
|
||||||
// Initialize features with injected dependencies
|
|
||||||
const {
|
|
||||||
chatTriggerNode,
|
|
||||||
connectedNode,
|
|
||||||
allowFileUploads,
|
|
||||||
allowedFilesMimeTypes,
|
|
||||||
setChatTriggerNode,
|
|
||||||
setConnectedNode,
|
|
||||||
} = useChatTrigger({
|
|
||||||
workflow,
|
|
||||||
canvasNodes,
|
|
||||||
getNodeByName: workflowsStore.getNodeByName,
|
|
||||||
getNodeType: nodeTypesStore.getNodeType,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { sendMessage, getChatMessages, isLoading } = useChatMessaging({
|
|
||||||
chatTrigger: chatTriggerNode,
|
|
||||||
connectedNode,
|
|
||||||
messages,
|
|
||||||
sessionId: currentSessionId,
|
|
||||||
workflow,
|
|
||||||
executionResultData: computed(() => workflowsStore.getWorkflowExecution?.data?.resultData),
|
|
||||||
getWorkflowResultDataByNodeName: workflowsStore.getWorkflowResultDataByNodeName,
|
|
||||||
onRunChatWorkflow,
|
|
||||||
});
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
height,
|
height,
|
||||||
|
@ -97,170 +42,28 @@ const {
|
||||||
onWindowResize,
|
onWindowResize,
|
||||||
} = useResize(container);
|
} = useResize(container);
|
||||||
|
|
||||||
// Extracted pure functions for better testability
|
const {
|
||||||
function createChatConfig(params: {
|
currentSessionId,
|
||||||
messages: Chat['messages'];
|
messages,
|
||||||
sendMessage: Chat['sendMessage'];
|
chatTriggerNode,
|
||||||
currentSessionId: Chat['currentSessionId'];
|
connectedNode,
|
||||||
isLoading: Ref<boolean>;
|
sendMessage,
|
||||||
isDisabled: Ref<boolean>;
|
refreshSession,
|
||||||
allowFileUploads: Ref<boolean>;
|
displayExecution,
|
||||||
locale: ReturnType<typeof useI18n>;
|
} = useChatState(isDisabled, onWindowResize);
|
||||||
}): { chatConfig: Chat; chatOptions: ChatOptions } {
|
|
||||||
const chatConfig: Chat = {
|
|
||||||
messages: params.messages,
|
|
||||||
sendMessage: params.sendMessage,
|
|
||||||
initialMessages: ref([]),
|
|
||||||
currentSessionId: params.currentSessionId,
|
|
||||||
waitingForResponse: params.isLoading,
|
|
||||||
};
|
|
||||||
|
|
||||||
const chatOptions: ChatOptions = {
|
// Expose internal state for testing
|
||||||
i18n: {
|
defineExpose({
|
||||||
en: {
|
messages,
|
||||||
title: '',
|
currentSessionId,
|
||||||
footer: '',
|
isDisabled,
|
||||||
subtitle: '',
|
workflow,
|
||||||
inputPlaceholder: params.locale.baseText('chat.window.chat.placeholder'),
|
});
|
||||||
getStarted: '',
|
|
||||||
closeButtonTooltip: '',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
webhookUrl: '',
|
|
||||||
mode: 'window',
|
|
||||||
showWindowCloseButton: true,
|
|
||||||
disabled: params.isDisabled,
|
|
||||||
allowFileUploads: params.allowFileUploads,
|
|
||||||
allowedFilesMimeTypes,
|
|
||||||
};
|
|
||||||
|
|
||||||
return { chatConfig, chatOptions };
|
|
||||||
}
|
|
||||||
|
|
||||||
function displayExecution(params: { router: Router; workflowId: string; executionId: string }) {
|
|
||||||
const route = params.router.resolve({
|
|
||||||
name: VIEWS.EXECUTION_PREVIEW,
|
|
||||||
params: { name: params.workflowId, executionId: params.executionId },
|
|
||||||
});
|
|
||||||
window.open(route.href, '_blank');
|
|
||||||
}
|
|
||||||
|
|
||||||
function refreshSession(params: { messages: Ref<ChatMessage[]>; currentSessionId: Ref<string> }) {
|
|
||||||
workflowsStore.setWorkflowExecutionData(null);
|
|
||||||
nodeHelpers.updateNodesExecutionIssues();
|
|
||||||
params.messages.value = [];
|
|
||||||
params.currentSessionId.value = uuid().replace(/-/g, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Event handlers
|
|
||||||
const handleDisplayExecution = (executionId: string) => {
|
|
||||||
displayExecution({
|
|
||||||
router,
|
|
||||||
workflowId: workflow.value.id,
|
|
||||||
executionId,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRefreshSession = () => {
|
|
||||||
refreshSession({
|
|
||||||
messages,
|
|
||||||
currentSessionId,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const closePanel = () => {
|
const closePanel = () => {
|
||||||
workflowsStore.setPanelOpen('chat', false);
|
workflowsStore.setPanelOpen('chat', false);
|
||||||
};
|
};
|
||||||
|
|
||||||
// This function creates a promise that resolves when the workflow execution completes
|
|
||||||
// It's used to handle the loading state while waiting for the workflow to finish
|
|
||||||
async function createExecutionPromise() {
|
|
||||||
return await new Promise<void>((resolve) => {
|
|
||||||
const resolveIfFinished = (isRunning: boolean) => {
|
|
||||||
if (!isRunning) {
|
|
||||||
unwatch();
|
|
||||||
resolve();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Watch for changes in the workflow execution status
|
|
||||||
const unwatch = watch(() => workflowsStore.isWorkflowRunning, resolveIfFinished);
|
|
||||||
resolveIfFinished(workflowsStore.isWorkflowRunning);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onRunChatWorkflow(payload: RunWorkflowChatPayload) {
|
|
||||||
const runWorkflowOptions: Parameters<typeof runWorkflow>[0] = {
|
|
||||||
triggerNode: payload.triggerNode,
|
|
||||||
nodeData: payload.nodeData,
|
|
||||||
source: payload.source,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (workflowsStore.chatPartialExecutionDestinationNode) {
|
|
||||||
runWorkflowOptions.destinationNode = workflowsStore.chatPartialExecutionDestinationNode;
|
|
||||||
workflowsStore.chatPartialExecutionDestinationNode = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await runWorkflow(runWorkflowOptions);
|
|
||||||
|
|
||||||
if (response) {
|
|
||||||
await createExecutionPromise();
|
|
||||||
workflowsStore.appendChatMessage(payload.message);
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize chat config
|
|
||||||
const { chatConfig, chatOptions } = createChatConfig({
|
|
||||||
messages,
|
|
||||||
sendMessage,
|
|
||||||
currentSessionId,
|
|
||||||
isLoading,
|
|
||||||
isDisabled,
|
|
||||||
allowFileUploads,
|
|
||||||
locale: useI18n(),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Provide chat context
|
|
||||||
provide(ChatSymbol, chatConfig);
|
|
||||||
provide(ChatOptionsSymbol, chatOptions);
|
|
||||||
|
|
||||||
// Watchers
|
|
||||||
watch(
|
|
||||||
() => isChatOpen.value,
|
|
||||||
(isOpen) => {
|
|
||||||
if (isOpen) {
|
|
||||||
setChatTriggerNode();
|
|
||||||
setConnectedNode();
|
|
||||||
|
|
||||||
if (messages.value.length === 0) {
|
|
||||||
messages.value = getChatMessages();
|
|
||||||
}
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
onWindowResize();
|
|
||||||
chatEventBus.emit('focusInput');
|
|
||||||
}, 0);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ immediate: true },
|
|
||||||
);
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => allConnections.value,
|
|
||||||
() => {
|
|
||||||
if (canvasStore.isLoading) return;
|
|
||||||
setTimeout(() => {
|
|
||||||
if (!chatTriggerNode.value) {
|
|
||||||
setChatTriggerNode();
|
|
||||||
}
|
|
||||||
setConnectedNode();
|
|
||||||
}, 0);
|
|
||||||
},
|
|
||||||
{ deep: true },
|
|
||||||
);
|
|
||||||
|
|
||||||
watchEffect(() => {
|
watchEffect(() => {
|
||||||
canvasStore.setPanelHeight(isChatOpen.value || isLogsOpen.value ? height.value : 0);
|
canvasStore.setPanelHeight(isChatOpen.value || isLogsOpen.value ? height.value : 0);
|
||||||
});
|
});
|
||||||
|
@ -293,8 +96,8 @@ watchEffect(() => {
|
||||||
:past-chat-messages="previousChatMessages"
|
:past-chat-messages="previousChatMessages"
|
||||||
:show-close-button="!connectedNode"
|
:show-close-button="!connectedNode"
|
||||||
@close="closePanel"
|
@close="closePanel"
|
||||||
@refresh-session="handleRefreshSession"
|
@refresh-session="refreshSession"
|
||||||
@display-execution="handleDisplayExecution"
|
@display-execution="displayExecution"
|
||||||
@send-message="sendMessage"
|
@send-message="sendMessage"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -18,15 +18,17 @@ interface Props {
|
||||||
messages: ChatMessage[];
|
messages: ChatMessage[];
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
showCloseButton?: boolean;
|
showCloseButton?: boolean;
|
||||||
|
isOpen?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<Props>();
|
const props = withDefaults(defineProps<Props>(), { isOpen: true });
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
displayExecution: [id: string];
|
displayExecution: [id: string];
|
||||||
sendMessage: [message: string];
|
sendMessage: [message: string];
|
||||||
refreshSession: [];
|
refreshSession: [];
|
||||||
close: [];
|
close: [];
|
||||||
|
clickHeader: [];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const messageComposable = useMessage();
|
const messageComposable = useMessage();
|
||||||
|
@ -142,10 +144,9 @@ function copySessionId() {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div :class="$style.chat" data-test-id="workflow-lm-chat-dialog">
|
<div :class="[$style.chat, isOpen ? $style.isOpen : null]" data-test-id="workflow-lm-chat-dialog">
|
||||||
<header :class="$style.chatHeader">
|
<PanelHeader :title="locale.baseText('chat.window.title')" @click="emit('clickHeader')">
|
||||||
<span :class="$style.chatTitle">{{ locale.baseText('chat.window.title') }}</span>
|
<template #actions>
|
||||||
<div :class="$style.session">
|
|
||||||
<span>{{ locale.baseText('chat.window.session.title') }}</span>
|
<span>{{ locale.baseText('chat.window.session.title') }}</span>
|
||||||
<n8n-tooltip placement="left">
|
<n8n-tooltip placement="left">
|
||||||
<template #content>
|
<template #content>
|
||||||
|
@ -174,9 +175,9 @@ function copySessionId() {
|
||||||
icon="times"
|
icon="times"
|
||||||
@click="emit('close')"
|
@click="emit('close')"
|
||||||
/>
|
/>
|
||||||
</div>
|
</template>
|
||||||
</header>
|
</PanelHeader>
|
||||||
<main :class="$style.chatBody">
|
<main v-if="isOpen" :class="$style.chatBody">
|
||||||
<MessagesList :messages="messages" :class="$style.messages">
|
<MessagesList :messages="messages" :class="$style.messages">
|
||||||
<template #beforeMessage="{ message }">
|
<template #beforeMessage="{ message }">
|
||||||
<MessageOptionTooltip
|
<MessageOptionTooltip
|
||||||
|
@ -209,7 +210,7 @@ function copySessionId() {
|
||||||
</MessagesList>
|
</MessagesList>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<div :class="$style.messagesInput">
|
<div v-if="isOpen" :class="$style.messagesInput">
|
||||||
<ChatInput
|
<ChatInput
|
||||||
data-test-id="lm-chat-inputs"
|
data-test-id="lm-chat-inputs"
|
||||||
:placeholder="inputPlaceholder"
|
:placeholder="inputPlaceholder"
|
||||||
|
@ -265,28 +266,7 @@ function copySessionId() {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background-color: var(--color-background-light);
|
background-color: var(--color-background-light);
|
||||||
}
|
}
|
||||||
.chatHeader {
|
|
||||||
font-size: var(--font-size-s);
|
|
||||||
font-weight: 400;
|
|
||||||
line-height: 18px;
|
|
||||||
text-align: left;
|
|
||||||
border-bottom: 1px solid var(--color-foreground-base);
|
|
||||||
padding: var(--chat--spacing);
|
|
||||||
background-color: var(--color-foreground-xlight);
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
.chatTitle {
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
.session {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--spacing-2xs);
|
|
||||||
color: var(--color-text-base);
|
|
||||||
max-width: 70%;
|
|
||||||
}
|
|
||||||
.sessionId {
|
.sessionId {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
@ -295,10 +275,6 @@ function copySessionId() {
|
||||||
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.headerButton {
|
|
||||||
max-height: 1.1rem;
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
.chatBody {
|
.chatBody {
|
||||||
display: flex;
|
display: flex;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|
|
@ -0,0 +1,53 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{ title: string }>();
|
||||||
|
|
||||||
|
defineSlots<{ actions: {} }>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{ click: [] }>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<header :class="$style.container" @click="emit('click')">
|
||||||
|
<span :class="$style.title">{{ title }}</span>
|
||||||
|
<div :class="$style.actions">
|
||||||
|
<slot name="actions" />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" module>
|
||||||
|
.container {
|
||||||
|
font-size: var(--font-size-s);
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 18px;
|
||||||
|
text-align: left;
|
||||||
|
padding: var(--spacing-xs);
|
||||||
|
background-color: var(--color-foreground-xlight);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
&:not(:last-child) {
|
||||||
|
border-bottom: 1px solid var(--color-foreground-base);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-weight: 600;
|
||||||
|
flex-grow: 1;
|
||||||
|
flex-shrink: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-2xs);
|
||||||
|
color: var(--color-text-base);
|
||||||
|
max-width: 70%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions button {
|
||||||
|
max-height: 1.1rem;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,229 @@
|
||||||
|
import type { RunWorkflowChatPayload } from '@/components/CanvasChat/composables/useChatMessaging';
|
||||||
|
import { useChatMessaging } from '@/components/CanvasChat/composables/useChatMessaging';
|
||||||
|
import { useChatTrigger } from '@/components/CanvasChat/composables/useChatTrigger';
|
||||||
|
import { useI18n } from '@/composables/useI18n';
|
||||||
|
import { useNodeHelpers } from '@/composables/useNodeHelpers';
|
||||||
|
import { useRunWorkflow } from '@/composables/useRunWorkflow';
|
||||||
|
import { VIEWS } from '@/constants';
|
||||||
|
import { type INodeUi } from '@/Interface';
|
||||||
|
import { useCanvasStore } from '@/stores/canvas.store';
|
||||||
|
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||||
|
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||||
|
import { ChatOptionsSymbol, ChatSymbol } from '@n8n/chat/constants';
|
||||||
|
import { chatEventBus } from '@n8n/chat/event-buses';
|
||||||
|
import type { Chat, ChatMessage, ChatOptions } from '@n8n/chat/types';
|
||||||
|
import { type INode } from 'n8n-workflow';
|
||||||
|
import { v4 as uuid } from 'uuid';
|
||||||
|
import type { Ref } from 'vue';
|
||||||
|
import { computed, provide, ref, watch } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
|
||||||
|
interface ChatState {
|
||||||
|
currentSessionId: Ref<string>;
|
||||||
|
messages: Ref<ChatMessage[]>;
|
||||||
|
chatTriggerNode: Ref<INodeUi | null>;
|
||||||
|
connectedNode: Ref<INode | null>;
|
||||||
|
sendMessage: (message: string, files?: File[]) => Promise<void>;
|
||||||
|
refreshSession: () => void;
|
||||||
|
displayExecution: (executionId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useChatState(isDisabled: Ref<boolean>, onWindowResize: () => void): ChatState {
|
||||||
|
const workflowsStore = useWorkflowsStore();
|
||||||
|
const nodeTypesStore = useNodeTypesStore();
|
||||||
|
const canvasStore = useCanvasStore();
|
||||||
|
const router = useRouter();
|
||||||
|
const nodeHelpers = useNodeHelpers();
|
||||||
|
const { runWorkflow } = useRunWorkflow({ router });
|
||||||
|
|
||||||
|
const messages = ref<ChatMessage[]>([]);
|
||||||
|
const currentSessionId = ref<string>(uuid().replace(/-/g, ''));
|
||||||
|
|
||||||
|
const canvasNodes = computed(() => workflowsStore.allNodes);
|
||||||
|
const allConnections = computed(() => workflowsStore.allConnections);
|
||||||
|
const isChatOpen = computed(() => {
|
||||||
|
const result = workflowsStore.isChatPanelOpen;
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
const workflow = computed(() => workflowsStore.getCurrentWorkflow());
|
||||||
|
|
||||||
|
// Initialize features with injected dependencies
|
||||||
|
const {
|
||||||
|
chatTriggerNode,
|
||||||
|
connectedNode,
|
||||||
|
allowFileUploads,
|
||||||
|
allowedFilesMimeTypes,
|
||||||
|
setChatTriggerNode,
|
||||||
|
setConnectedNode,
|
||||||
|
} = useChatTrigger({
|
||||||
|
workflow,
|
||||||
|
canvasNodes,
|
||||||
|
getNodeByName: workflowsStore.getNodeByName,
|
||||||
|
getNodeType: nodeTypesStore.getNodeType,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { sendMessage, getChatMessages, isLoading } = useChatMessaging({
|
||||||
|
chatTrigger: chatTriggerNode,
|
||||||
|
connectedNode,
|
||||||
|
messages,
|
||||||
|
sessionId: currentSessionId,
|
||||||
|
workflow,
|
||||||
|
executionResultData: computed(() => workflowsStore.getWorkflowExecution?.data?.resultData),
|
||||||
|
getWorkflowResultDataByNodeName: workflowsStore.getWorkflowResultDataByNodeName,
|
||||||
|
onRunChatWorkflow,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Extracted pure functions for better testability
|
||||||
|
function createChatConfig(params: {
|
||||||
|
messages: Chat['messages'];
|
||||||
|
sendMessage: Chat['sendMessage'];
|
||||||
|
currentSessionId: Chat['currentSessionId'];
|
||||||
|
isLoading: Ref<boolean>;
|
||||||
|
isDisabled: Ref<boolean>;
|
||||||
|
allowFileUploads: Ref<boolean>;
|
||||||
|
locale: ReturnType<typeof useI18n>;
|
||||||
|
}): { chatConfig: Chat; chatOptions: ChatOptions } {
|
||||||
|
const chatConfig: Chat = {
|
||||||
|
messages: params.messages,
|
||||||
|
sendMessage: params.sendMessage,
|
||||||
|
initialMessages: ref([]),
|
||||||
|
currentSessionId: params.currentSessionId,
|
||||||
|
waitingForResponse: params.isLoading,
|
||||||
|
};
|
||||||
|
|
||||||
|
const chatOptions: ChatOptions = {
|
||||||
|
i18n: {
|
||||||
|
en: {
|
||||||
|
title: '',
|
||||||
|
footer: '',
|
||||||
|
subtitle: '',
|
||||||
|
inputPlaceholder: params.locale.baseText('chat.window.chat.placeholder'),
|
||||||
|
getStarted: '',
|
||||||
|
closeButtonTooltip: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
webhookUrl: '',
|
||||||
|
mode: 'window',
|
||||||
|
showWindowCloseButton: true,
|
||||||
|
disabled: params.isDisabled,
|
||||||
|
allowFileUploads: params.allowFileUploads,
|
||||||
|
allowedFilesMimeTypes,
|
||||||
|
};
|
||||||
|
|
||||||
|
return { chatConfig, chatOptions };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize chat config
|
||||||
|
const { chatConfig, chatOptions } = createChatConfig({
|
||||||
|
messages,
|
||||||
|
sendMessage,
|
||||||
|
currentSessionId,
|
||||||
|
isLoading,
|
||||||
|
isDisabled,
|
||||||
|
allowFileUploads,
|
||||||
|
locale: useI18n(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Provide chat context
|
||||||
|
provide(ChatSymbol, chatConfig);
|
||||||
|
provide(ChatOptionsSymbol, chatOptions);
|
||||||
|
|
||||||
|
// Watchers
|
||||||
|
watch(
|
||||||
|
() => isChatOpen.value,
|
||||||
|
(isOpen) => {
|
||||||
|
if (isOpen) {
|
||||||
|
setChatTriggerNode();
|
||||||
|
setConnectedNode();
|
||||||
|
|
||||||
|
if (messages.value.length === 0) {
|
||||||
|
messages.value = getChatMessages();
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
onWindowResize();
|
||||||
|
chatEventBus.emit('focusInput');
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => allConnections.value,
|
||||||
|
() => {
|
||||||
|
if (canvasStore.isLoading) return;
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!chatTriggerNode.value) {
|
||||||
|
setChatTriggerNode();
|
||||||
|
}
|
||||||
|
setConnectedNode();
|
||||||
|
}, 0);
|
||||||
|
},
|
||||||
|
{ deep: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
// This function creates a promise that resolves when the workflow execution completes
|
||||||
|
// It's used to handle the loading state while waiting for the workflow to finish
|
||||||
|
async function createExecutionPromise() {
|
||||||
|
return await new Promise<void>((resolve) => {
|
||||||
|
const resolveIfFinished = (isRunning: boolean) => {
|
||||||
|
if (!isRunning) {
|
||||||
|
unwatch();
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Watch for changes in the workflow execution status
|
||||||
|
const unwatch = watch(() => workflowsStore.isWorkflowRunning, resolveIfFinished);
|
||||||
|
resolveIfFinished(workflowsStore.isWorkflowRunning);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onRunChatWorkflow(payload: RunWorkflowChatPayload) {
|
||||||
|
const runWorkflowOptions: Parameters<typeof runWorkflow>[0] = {
|
||||||
|
triggerNode: payload.triggerNode,
|
||||||
|
nodeData: payload.nodeData,
|
||||||
|
source: payload.source,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (workflowsStore.chatPartialExecutionDestinationNode) {
|
||||||
|
runWorkflowOptions.destinationNode = workflowsStore.chatPartialExecutionDestinationNode;
|
||||||
|
workflowsStore.chatPartialExecutionDestinationNode = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await runWorkflow(runWorkflowOptions);
|
||||||
|
|
||||||
|
if (response) {
|
||||||
|
await createExecutionPromise();
|
||||||
|
workflowsStore.appendChatMessage(payload.message);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshSession() {
|
||||||
|
workflowsStore.setWorkflowExecutionData(null);
|
||||||
|
nodeHelpers.updateNodesExecutionIssues();
|
||||||
|
messages.value = [];
|
||||||
|
currentSessionId.value = uuid().replace(/-/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayExecution(executionId: string) {
|
||||||
|
const route = router.resolve({
|
||||||
|
name: VIEWS.EXECUTION_PREVIEW,
|
||||||
|
params: { name: workflow.value.id, executionId },
|
||||||
|
});
|
||||||
|
window.open(route.href, '_blank');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentSessionId,
|
||||||
|
messages,
|
||||||
|
chatTriggerNode,
|
||||||
|
connectedNode,
|
||||||
|
sendMessage,
|
||||||
|
refreshSession,
|
||||||
|
displayExecution,
|
||||||
|
};
|
||||||
|
}
|
|
@ -0,0 +1,62 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { N8nIconButton } from '@n8n/design-system';
|
||||||
|
import { useChatState } from '@/components/CanvasChat/composables/useChatState';
|
||||||
|
import { useResize } from '@/components/CanvasChat/composables/useResize';
|
||||||
|
|
||||||
|
const workflowsStore = useWorkflowsStore();
|
||||||
|
const isOpen = computed(() => workflowsStore.isChatPanelOpen || workflowsStore.isLogsPanelOpen);
|
||||||
|
const container = ref<HTMLElement>();
|
||||||
|
const previousChatMessages = computed(() => workflowsStore.getPastChatMessages);
|
||||||
|
|
||||||
|
const { onWindowResize } = useResize(container);
|
||||||
|
|
||||||
|
const { currentSessionId, messages, connectedNode, sendMessage, refreshSession, displayExecution } =
|
||||||
|
useChatState(ref(false), onWindowResize);
|
||||||
|
|
||||||
|
function handleToggleOpen() {
|
||||||
|
workflowsStore.setPanelOpen('chat', !isOpen.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClickHeader() {
|
||||||
|
if (!isOpen.value) {
|
||||||
|
workflowsStore.setPanelOpen('chat', true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div ref="container" :class="$style.container">
|
||||||
|
<ChatMessagesPanel
|
||||||
|
data-test-id="canvas-chat"
|
||||||
|
:is-open="isOpen"
|
||||||
|
:messages="messages"
|
||||||
|
:session-id="currentSessionId"
|
||||||
|
:past-chat-messages="previousChatMessages"
|
||||||
|
:show-close-button="!connectedNode"
|
||||||
|
@close="handleToggleOpen"
|
||||||
|
@refresh-session="refreshSession"
|
||||||
|
@display-execution="displayExecution"
|
||||||
|
@send-message="sendMessage"
|
||||||
|
@click-header="handleClickHeader"
|
||||||
|
/>
|
||||||
|
<LogsPanel :is-open="isOpen" @click-header="handleClickHeader">
|
||||||
|
<template #actions
|
||||||
|
><N8nIconButton
|
||||||
|
type="secondary"
|
||||||
|
size="mini"
|
||||||
|
:icon="isOpen ? 'chevron-down' : 'chevron-up'"
|
||||||
|
@click.stop="handleToggleOpen"
|
||||||
|
/></template>
|
||||||
|
</LogsPanel>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" module>
|
||||||
|
.container {
|
||||||
|
border-top: 1px solid var(--color-foreground-base);
|
||||||
|
background-color: var(--color-background-light);
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,29 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{ isOpen: boolean }>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{ clickHeader: [] }>();
|
||||||
|
|
||||||
|
defineSlots<{ actions: {} }>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div :class="$style.container">
|
||||||
|
<PanelHeader title="Logs" @click="emit('clickHeader')">
|
||||||
|
<template #actions>
|
||||||
|
<slot name="actions" />
|
||||||
|
</template>
|
||||||
|
</PanelHeader>
|
||||||
|
<div v-if="isOpen" :class="$style.content">body...</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" module>
|
||||||
|
.container {
|
||||||
|
flex-grow: 1;
|
||||||
|
flex-shrink: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
padding: var(--spacing-2xs);
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -26,7 +26,8 @@ const ErrorView = async () => await import('./views/ErrorView.vue');
|
||||||
const ForgotMyPasswordView = async () => await import('./views/ForgotMyPasswordView.vue');
|
const ForgotMyPasswordView = async () => await import('./views/ForgotMyPasswordView.vue');
|
||||||
const MainHeader = async () => await import('@/components/MainHeader/MainHeader.vue');
|
const MainHeader = async () => await import('@/components/MainHeader/MainHeader.vue');
|
||||||
const MainSidebar = async () => await import('@/components/MainSidebar.vue');
|
const MainSidebar = async () => await import('@/components/MainSidebar.vue');
|
||||||
const CanvasChat = async () => await import('@/components/CanvasChat/CanvasChat.vue');
|
const CanvasBottomPanel = async () =>
|
||||||
|
await import('@/components/CanvasChat/v2/CanvasBottomPanel.vue');
|
||||||
const NodeView = async () => await import('@/views/NodeView.vue');
|
const NodeView = async () => await import('@/views/NodeView.vue');
|
||||||
const WorkflowExecutionsView = async () => await import('@/views/WorkflowExecutionsView.vue');
|
const WorkflowExecutionsView = async () => await import('@/views/WorkflowExecutionsView.vue');
|
||||||
const WorkflowExecutionsLandingPage = async () =>
|
const WorkflowExecutionsLandingPage = async () =>
|
||||||
|
@ -358,7 +359,7 @@ export const routes: RouteRecordRaw[] = [
|
||||||
default: NodeView,
|
default: NodeView,
|
||||||
header: MainHeader,
|
header: MainHeader,
|
||||||
sidebar: MainSidebar,
|
sidebar: MainSidebar,
|
||||||
footer: CanvasChat,
|
footer: CanvasBottomPanel,
|
||||||
},
|
},
|
||||||
meta: {
|
meta: {
|
||||||
nodeView: true,
|
nodeView: true,
|
||||||
|
@ -391,7 +392,7 @@ export const routes: RouteRecordRaw[] = [
|
||||||
default: NodeView,
|
default: NodeView,
|
||||||
header: MainHeader,
|
header: MainHeader,
|
||||||
sidebar: MainSidebar,
|
sidebar: MainSidebar,
|
||||||
footer: CanvasChat,
|
footer: CanvasBottomPanel,
|
||||||
},
|
},
|
||||||
meta: {
|
meta: {
|
||||||
nodeView: true,
|
nodeView: true,
|
||||||
|
|
Loading…
Reference in a new issue