mirror of
https://github.com/n8n-io/n8n.git
synced 2025-03-05 20:50:17 -08:00
WIP: Refactor to composables
This commit is contained in:
parent
f9e177958c
commit
34b0fc70b6
|
@ -16,6 +16,7 @@
|
|||
"build:nodes": "turbo run build:nodes",
|
||||
"typecheck": "turbo typecheck",
|
||||
"dev": "turbo run dev --parallel --env-mode=loose --filter=!n8n-design-system --filter=!@n8n/chat --filter=!@n8n/task-runner",
|
||||
"dev:be": "turbo run dev --parallel --env-mode=loose --filter=!n8n-design-system --filter=!@n8n/chat --filter=!@n8n/task-runner --filter=!n8n-editor-ui",
|
||||
"dev:ai": "turbo run dev --parallel --env-mode=loose --filter=@n8n/nodes-langchain --filter=n8n --filter=n8n-core",
|
||||
"clean": "turbo run clean --parallel",
|
||||
"reset": "node scripts/ensure-zx.mjs && zx scripts/reset.mjs",
|
||||
|
|
|
@ -132,7 +132,7 @@ onMounted(async () => {
|
|||
.chat-message {
|
||||
display: block;
|
||||
position: relative;
|
||||
max-width: 80%;
|
||||
max-width: fit-content;
|
||||
font-size: var(--chat--message--font-size, 1rem);
|
||||
padding: var(--chat--message--padding, var(--chat--spacing));
|
||||
border-radius: var(--chat--message--border-radius, var(--chat--border-radius));
|
||||
|
@ -159,7 +159,7 @@ onMounted(async () => {
|
|||
}
|
||||
|
||||
p {
|
||||
line-height: var(--chat--message-line-height, 1.8);
|
||||
line-height: var(--chat--message-line-height, 1.5);
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
|
|
|
@ -31,15 +31,11 @@ const loading = ref(true);
|
|||
const defaultLocale = computed(() => rootStore.defaultLocale);
|
||||
const isDemoMode = computed(() => route.name === VIEWS.DEMO);
|
||||
const showAssistantButton = computed(() => assistantStore.canShowAssistantButtonsOnCanvas);
|
||||
|
||||
const hasContentFooter = ref(false);
|
||||
const appGrid = ref<Element | null>(null);
|
||||
|
||||
const assistantSidebarWidth = computed(() => assistantStore.chatWidth);
|
||||
|
||||
watch(defaultLocale, (newLocale) => {
|
||||
void loadLanguage(newLocale);
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
logHiringBanner();
|
||||
void useExternalHooks().run('app.mount');
|
||||
|
@ -52,11 +48,6 @@ onBeforeUnmount(() => {
|
|||
window.removeEventListener('resize', updateGridWidth);
|
||||
});
|
||||
|
||||
// As assistant sidebar width changes, recalculate the total width regularly
|
||||
watch(assistantSidebarWidth, async () => {
|
||||
await updateGridWidth();
|
||||
});
|
||||
|
||||
const logHiringBanner = () => {
|
||||
if (settingsStore.isHiringBannerEnabled && !isDemoMode.value) {
|
||||
console.log(HIRING_BANNER);
|
||||
|
@ -69,6 +60,21 @@ const updateGridWidth = async () => {
|
|||
uiStore.appGridWidth = appGrid.value.clientWidth;
|
||||
}
|
||||
};
|
||||
|
||||
// As assistant sidebar width changes, recalculate the total width regularly
|
||||
watch(assistantSidebarWidth, async () => {
|
||||
await updateGridWidth();
|
||||
});
|
||||
|
||||
watch(route, (r) => {
|
||||
hasContentFooter.value = r.matched.some(
|
||||
(matchedRoute) => matchedRoute.components?.footer !== undefined,
|
||||
);
|
||||
});
|
||||
|
||||
watch(defaultLocale, (newLocale) => {
|
||||
void loadLanguage(newLocale);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
@ -98,7 +104,7 @@ const updateGridWidth = async () => {
|
|||
</keep-alive>
|
||||
<component :is="Component" v-else />
|
||||
</router-view>
|
||||
<div :class="$style.contentFooter">
|
||||
<div :class="$style.contentFooter" v-if="hasContentFooter">
|
||||
<router-view name="footer" />
|
||||
</div>
|
||||
</div>
|
||||
|
@ -149,6 +155,7 @@ const updateGridWidth = async () => {
|
|||
width: 100%;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
main {
|
||||
width: 100%;
|
||||
|
@ -157,9 +164,9 @@ const updateGridWidth = async () => {
|
|||
|
||||
.contentFooter {
|
||||
height: auto;
|
||||
z-index: 1000000;
|
||||
z-index: 10;
|
||||
width: 100%;
|
||||
background: white;
|
||||
border: 1px solid red;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,48 +1,115 @@
|
|||
<script setup lang="ts">
|
||||
import { useAssistantStore } from '@/stores/assistant.store';
|
||||
import { useDebounce } from '@/composables/useDebounce';
|
||||
import { useUsersStore } from '@/stores/users.store';
|
||||
import { computed, ref } from 'vue';
|
||||
import SlideTransition from '@/components/transitions/SlideTransition.vue';
|
||||
import AskAssistantChat from 'n8n-design-system/components/AskAssistantChat/AskAssistantChat.vue';
|
||||
import { useTelemetry } from '@/composables/useTelemetry';
|
||||
import { provide, watch, computed, ref } from 'vue';
|
||||
import { ChatOptionsSymbol, ChatSymbol } from '@n8n/chat/constants';
|
||||
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { chatEventBus } from '@n8n/chat/event-buses';
|
||||
import { useCanvasStore } from '@/stores/canvas.store';
|
||||
import { VIEWS } from '@/constants';
|
||||
import ChatMessagesPanel from './components/ChatMessagesPanel.vue';
|
||||
import ChatLogsPanel from './components/ChatLogsPanel.vue';
|
||||
import { useChatTrigger } from './composables/useChatTrigger';
|
||||
import { useChatMessaging } from './composables/useChatMessaging';
|
||||
import { useResize } from './composables/useResize';
|
||||
import type { Chat, ChatMessage, ChatOptions } from '@n8n/chat/types';
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
|
||||
const assistantStore = useAssistantStore();
|
||||
const usersStore = useUsersStore();
|
||||
const telemetry = useTelemetry();
|
||||
const router = useRouter();
|
||||
const uiStore = useUIStore();
|
||||
const locale = useI18n();
|
||||
const { getCurrentWorkflow } = useWorkflowHelpers({ router });
|
||||
|
||||
const height = ref(0);
|
||||
const messages = ref<ChatMessage[]>([]);
|
||||
const currentSessionId = ref<string>(String(Date.now()));
|
||||
const isDisabled = ref(false);
|
||||
const container = ref<HTMLElement>();
|
||||
const {
|
||||
chatTrigger,
|
||||
node,
|
||||
allowFileUploads,
|
||||
setChatTriggerNode,
|
||||
setConnectedNode,
|
||||
setLogsSourceNode,
|
||||
} = useChatTrigger({ router });
|
||||
|
||||
const chatWidth = ref(0);
|
||||
const logsWidth = ref(0);
|
||||
const {
|
||||
sendMessage,
|
||||
getChatMessages,
|
||||
// extractResponseMessage,
|
||||
// waitForExecution,
|
||||
// onArrowKeyDown,
|
||||
} = useChatMessaging({ chatTrigger, messages, router });
|
||||
|
||||
const rootStyles = computed(() => {
|
||||
return {
|
||||
'--chat-width': chatWidth.value + 'px',
|
||||
'--logs-width': logsWidth.value + 'px',
|
||||
'--panel-height': height.value + 'px',
|
||||
};
|
||||
});
|
||||
function onResize(data) {
|
||||
console.log('🚀 ~ onResize ~ direction:', data);
|
||||
height.value = data.height;
|
||||
// assistantStore.updateWindowWidth(data.width);
|
||||
const { height, chatWidth, rootStyles, onResizeDebounced, onResizeChat } = useResize(container);
|
||||
|
||||
const isLoading = computed(() => uiStore.isActionActive.workflowRunning);
|
||||
|
||||
const chatConfig: Chat = {
|
||||
messages,
|
||||
sendMessage,
|
||||
initialMessages: ref([]),
|
||||
currentSessionId,
|
||||
waitingForResponse: isLoading,
|
||||
};
|
||||
|
||||
const chatOptions: ChatOptions = {
|
||||
i18n: {
|
||||
en: {
|
||||
title: '',
|
||||
footer: '',
|
||||
subtitle: '',
|
||||
inputPlaceholder: locale.baseText('chat.window.chat.placeholder'),
|
||||
getStarted: '',
|
||||
closeButtonTooltip: '',
|
||||
},
|
||||
},
|
||||
webhookUrl: '',
|
||||
mode: 'window',
|
||||
showWindowCloseButton: true,
|
||||
disabled: isDisabled,
|
||||
allowFileUploads: false,
|
||||
allowedFilesMimeTypes: '',
|
||||
};
|
||||
|
||||
watch(
|
||||
() => allowFileUploads.value,
|
||||
(newValue) => {
|
||||
chatOptions.allowFileUploads = newValue;
|
||||
},
|
||||
);
|
||||
|
||||
function displayExecution(executionId: string) {
|
||||
const workflow = getCurrentWorkflow();
|
||||
const route = router.resolve({
|
||||
name: VIEWS.EXECUTION_PREVIEW,
|
||||
params: { name: workflow.id, executionId },
|
||||
});
|
||||
window.open(route.href, '_blank');
|
||||
}
|
||||
|
||||
function onResizeDebounced(data: { direction: string; x: number; width: number }) {
|
||||
void useDebounce().callDebounced(onResize, { debounceTime: 10, trailing: true }, data);
|
||||
}
|
||||
provide(ChatSymbol, chatConfig);
|
||||
provide(ChatOptionsSymbol, chatOptions);
|
||||
|
||||
function onResizeHorizontal(
|
||||
component: 'chat' | 'logs',
|
||||
data: { direction: string; x: number; width: number },
|
||||
) {
|
||||
if (component === 'chat') {
|
||||
chatWidth.value = data.width;
|
||||
} else {
|
||||
logsWidth.value = data.width;
|
||||
}
|
||||
}
|
||||
const canvasStore = useCanvasStore();
|
||||
watch(
|
||||
() => canvasStore.isLoading,
|
||||
() => {
|
||||
setChatTriggerNode();
|
||||
setConnectedNode();
|
||||
messages.value = getChatMessages();
|
||||
|
||||
setTimeout(() => chatEventBus.emit('focusInput'), 0);
|
||||
},
|
||||
);
|
||||
|
||||
// Update logs source node when messages change
|
||||
watch(
|
||||
() => messages.value.length,
|
||||
() => {
|
||||
setLogsSourceNode();
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
@ -52,20 +119,28 @@ function onResizeHorizontal(
|
|||
:class="$style.container"
|
||||
:height="height"
|
||||
data-test-id="ask-assistant-sidebar"
|
||||
@resize="onResizeDebounced"
|
||||
:style="rootStyles"
|
||||
@resize="onResizeDebounced"
|
||||
>
|
||||
<div :class="$style.content">
|
||||
<div ref="container" :class="$style.content">
|
||||
<n8n-resize-wrapper
|
||||
v-show="true"
|
||||
:supported-directions="['right']"
|
||||
:width="chatWidth"
|
||||
:class="$style.chat"
|
||||
@resize="(data) => onResizeHorizontal('chat', data)"
|
||||
@resize="onResizeChat"
|
||||
>
|
||||
Chat
|
||||
<div :class="$style.inner">
|
||||
<ChatMessagesPanel
|
||||
:messages="messages"
|
||||
@display-execution="displayExecution"
|
||||
@send-message="sendMessage"
|
||||
/>
|
||||
</div>
|
||||
</n8n-resize-wrapper>
|
||||
<div :class="$style.logs" @resize="(data) => onResizeHorizontal('logs', data)">Logs</div>
|
||||
<div :class="$style.logs">
|
||||
<ChatLogsPanel :node="node" :messages-length="messages.length" />
|
||||
</div>
|
||||
</div>
|
||||
</n8n-resize-wrapper>
|
||||
</template>
|
||||
|
@ -73,22 +148,36 @@ function onResizeHorizontal(
|
|||
<style lang="scss" module>
|
||||
.container {
|
||||
height: var(--panel-height);
|
||||
min-height: 3rem;
|
||||
min-height: 4rem;
|
||||
flex-basis: content;
|
||||
z-index: 300;
|
||||
// transform: translateY(-18rem);
|
||||
border-top: 1px solid var(--color-foreground-base);
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat {
|
||||
width: var(--chat-width);
|
||||
min-width: 5rem;
|
||||
border-right: 2px solid var(--color-foreground-base);
|
||||
border-right: 1px solid var(--color-foreground-base);
|
||||
flex-shrink: 0;
|
||||
flex-grow: 0;
|
||||
}
|
||||
|
||||
.inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.logs {
|
||||
width: var(--logs-width);
|
||||
flex-grow: 1;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -0,0 +1,31 @@
|
|||
<script setup lang="ts">
|
||||
const emit = defineEmits<{
|
||||
click: [];
|
||||
}>();
|
||||
|
||||
defineProps<{
|
||||
label: string;
|
||||
icon: string;
|
||||
placement: 'left' | 'right' | 'top' | 'bottom';
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<N8nTooltip :placement="placement">
|
||||
<button :class="$style.button" :style="{ color: '#aaa' }" @click="emit('click')">
|
||||
<N8nIcon :icon="icon" size="small" />
|
||||
</button>
|
||||
<template #content>
|
||||
{{ label }}
|
||||
</template>
|
||||
</N8nTooltip>
|
||||
</template>
|
||||
|
||||
<style module>
|
||||
.button {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,15 @@
|
|||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
placement: 'left' | 'right';
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n8n-info-tip type="tooltip" theme="info-light" :tooltip-placement="placement">
|
||||
<n8n-text :bold="true" size="small">
|
||||
<slot />
|
||||
</n8n-text>
|
||||
</n8n-info-tip>
|
||||
</template>
|
||||
|
||||
<style module lang="scss"></style>
|
696
packages/editor-ui/src/components/CanvasChat/WorkflowLMChat.vue
Normal file
696
packages/editor-ui/src/components/CanvasChat/WorkflowLMChat.vue
Normal file
|
@ -0,0 +1,696 @@
|
|||
<script setup lang="ts">
|
||||
import type { Ref } from 'vue';
|
||||
import { defineAsyncComponent, provide, ref, computed, onMounted, nextTick } from 'vue';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import Modal from '@/components/Modal.vue';
|
||||
import {
|
||||
AI_CATEGORY_AGENTS,
|
||||
AI_CATEGORY_CHAINS,
|
||||
AI_CODE_NODE_TYPE,
|
||||
AI_SUBCATEGORY,
|
||||
CHAT_EMBED_MODAL_KEY,
|
||||
CHAT_TRIGGER_NODE_TYPE,
|
||||
MANUAL_CHAT_TRIGGER_NODE_TYPE,
|
||||
MODAL_CONFIRM,
|
||||
VIEWS,
|
||||
WORKFLOW_LM_CHAT_MODAL_KEY,
|
||||
} from '@/constants';
|
||||
|
||||
import { useUsersStore } from '@/stores/users.store';
|
||||
|
||||
// eslint-disable-next-line import/no-unresolved
|
||||
import MessagesList from '@n8n/chat/components/MessagesList.vue';
|
||||
import type { ArrowKeyDownPayload } from '@n8n/chat/components/Input.vue';
|
||||
import ChatInput from '@n8n/chat/components/Input.vue';
|
||||
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useRunWorkflow } from '@/composables/useRunWorkflow';
|
||||
import type { Chat, ChatMessage, ChatMessageText, ChatOptions } from '@n8n/chat/types';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { ChatOptionsSymbol, ChatSymbol } from '@n8n/chat/constants';
|
||||
import MessageOptionTooltip from './MessageOptionTooltip.vue';
|
||||
import MessageOptionAction from './MessageOptionAction.vue';
|
||||
|
||||
import type {
|
||||
BinaryFileType,
|
||||
IBinaryData,
|
||||
IBinaryKeyData,
|
||||
IDataObject,
|
||||
INode,
|
||||
INodeExecutionData,
|
||||
INodeParameters,
|
||||
INodeType,
|
||||
ITaskData,
|
||||
IUser,
|
||||
} from 'n8n-workflow';
|
||||
import {
|
||||
CHAIN_SUMMARIZATION_LANGCHAIN_NODE_TYPE,
|
||||
NodeConnectionType,
|
||||
NodeHelpers,
|
||||
} from 'n8n-workflow';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
import type { INodeUi } from '@/Interface';
|
||||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||
import { createEventBus } from 'n8n-design-system';
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
import { useMessage } from '@/composables/useMessage';
|
||||
import { usePinnedData } from '@/composables/usePinnedData';
|
||||
import { get, last } from 'lodash-es';
|
||||
import { isEmpty } from '@/utils/typesUtils';
|
||||
import { chatEventBus } from '@n8n/chat/event-buses';
|
||||
|
||||
const LazyRunDataAi = defineAsyncComponent(
|
||||
async () => await import('@/components/RunDataAi/RunDataAi.vue'),
|
||||
);
|
||||
|
||||
// TODO: Add proper type
|
||||
interface LangChainMessage {
|
||||
id: string[];
|
||||
kwargs: {
|
||||
content: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface MemoryOutput {
|
||||
action: string;
|
||||
chatHistory?: LangChainMessage[];
|
||||
}
|
||||
|
||||
const router = useRouter();
|
||||
const workflowHelpers = useWorkflowHelpers({ router });
|
||||
const { runWorkflow } = useRunWorkflow({ router });
|
||||
const workflowsStore = useWorkflowsStore();
|
||||
const nodeTypesStore = useNodeTypesStore();
|
||||
const uiStore = useUIStore();
|
||||
|
||||
const { showError } = useToast();
|
||||
const messages: Ref<ChatMessage[]> = ref([]);
|
||||
const currentSessionId = ref<string>(String(Date.now()));
|
||||
const isDisabled = ref(false);
|
||||
|
||||
const connectedNode = ref<INode | null>(null);
|
||||
const chatTrigger = ref<INode | null>(null);
|
||||
const modalBus = createEventBus();
|
||||
const node = ref<INode | null>(null);
|
||||
const previousMessageIndex = ref(0);
|
||||
|
||||
const isLoading = computed(() => uiStore.isActionActive.workflowRunning);
|
||||
const allowFileUploads = computed(() => {
|
||||
return (chatTrigger.value?.parameters?.options as INodeParameters)?.allowFileUploads === true;
|
||||
});
|
||||
const allowedFilesMimeTypes = computed(() => {
|
||||
return (
|
||||
(
|
||||
chatTrigger.value?.parameters?.options as INodeParameters
|
||||
)?.allowedFilesMimeTypes?.toString() ?? ''
|
||||
);
|
||||
});
|
||||
const locale = useI18n();
|
||||
|
||||
const chatOptions: ChatOptions = {
|
||||
i18n: {
|
||||
en: {
|
||||
title: '',
|
||||
footer: '',
|
||||
subtitle: '',
|
||||
inputPlaceholder: locale.baseText('chat.window.chat.placeholder'),
|
||||
getStarted: '',
|
||||
closeButtonTooltip: '',
|
||||
},
|
||||
},
|
||||
webhookUrl: '',
|
||||
mode: 'window',
|
||||
showWindowCloseButton: true,
|
||||
disabled: isDisabled,
|
||||
allowFileUploads,
|
||||
allowedFilesMimeTypes,
|
||||
};
|
||||
|
||||
const chatConfig: Chat = {
|
||||
messages,
|
||||
sendMessage,
|
||||
initialMessages: ref([]),
|
||||
currentSessionId,
|
||||
waitingForResponse: isLoading,
|
||||
};
|
||||
|
||||
const messageVars = {
|
||||
'--chat--message--bot--background': 'var(--color-lm-chat-bot-background)',
|
||||
'--chat--message--user--background': 'var(--color-lm-chat-user-background)',
|
||||
'--chat--message--bot--color': 'var(--color-text-dark)',
|
||||
'--chat--message--user--color': 'var(--color-lm-chat-user-color)',
|
||||
'--chat--message--bot--border': 'none',
|
||||
'--chat--message--user--border': 'none',
|
||||
'--chat--color-typing': 'var(--color-text-dark)',
|
||||
};
|
||||
|
||||
function getTriggerNode() {
|
||||
const workflow = workflowHelpers.getCurrentWorkflow();
|
||||
const triggerNode = workflow.queryNodes((nodeType: INodeType) =>
|
||||
[CHAT_TRIGGER_NODE_TYPE, MANUAL_CHAT_TRIGGER_NODE_TYPE].includes(nodeType.description.name),
|
||||
);
|
||||
|
||||
if (!triggerNode.length) {
|
||||
chatTrigger.value = null;
|
||||
}
|
||||
|
||||
chatTrigger.value = triggerNode[0];
|
||||
}
|
||||
|
||||
function setNode() {
|
||||
const triggerNode = chatTrigger.value;
|
||||
if (!triggerNode) {
|
||||
return;
|
||||
}
|
||||
|
||||
const workflow = workflowHelpers.getCurrentWorkflow();
|
||||
const childNodes = workflow.getChildNodes(triggerNode.name);
|
||||
|
||||
for (const childNode of childNodes) {
|
||||
// Look for the first connected node with metadata
|
||||
// TODO: Allow later users to change that in the UI
|
||||
const resultData = workflowsStore.getWorkflowResultDataByNodeName(childNode);
|
||||
|
||||
if (!resultData && !Array.isArray(resultData)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (resultData[resultData.length - 1].metadata) {
|
||||
node.value = workflowsStore.getNodeByName(childNode);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setConnectedNode() {
|
||||
const triggerNode = chatTrigger.value;
|
||||
|
||||
if (!triggerNode) {
|
||||
showError(new Error('Chat Trigger Node could not be found!'), 'Trigger Node not found');
|
||||
return;
|
||||
}
|
||||
const workflow = workflowHelpers.getCurrentWorkflow();
|
||||
|
||||
const chatNode = workflowsStore.getNodes().find((storeNode: INodeUi): boolean => {
|
||||
if (storeNode.type === CHAIN_SUMMARIZATION_LANGCHAIN_NODE_TYPE) return false;
|
||||
const nodeType = nodeTypesStore.getNodeType(storeNode.type, storeNode.typeVersion);
|
||||
if (!nodeType) return false;
|
||||
|
||||
const isAgent = nodeType.codex?.subcategories?.[AI_SUBCATEGORY]?.includes(AI_CATEGORY_AGENTS);
|
||||
const isChain = nodeType.codex?.subcategories?.[AI_SUBCATEGORY]?.includes(AI_CATEGORY_CHAINS);
|
||||
|
||||
let isCustomChainOrAgent = false;
|
||||
if (nodeType.name === AI_CODE_NODE_TYPE) {
|
||||
const inputs = NodeHelpers.getNodeInputs(workflow, storeNode, nodeType);
|
||||
const inputTypes = NodeHelpers.getConnectionTypes(inputs);
|
||||
|
||||
const outputs = NodeHelpers.getNodeOutputs(workflow, storeNode, nodeType);
|
||||
const outputTypes = NodeHelpers.getConnectionTypes(outputs);
|
||||
|
||||
if (
|
||||
inputTypes.includes(NodeConnectionType.AiLanguageModel) &&
|
||||
inputTypes.includes(NodeConnectionType.Main) &&
|
||||
outputTypes.includes(NodeConnectionType.Main)
|
||||
) {
|
||||
isCustomChainOrAgent = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isAgent && !isChain && !isCustomChainOrAgent) return false;
|
||||
|
||||
const parentNodes = workflow.getParentNodes(storeNode.name);
|
||||
const isChatChild = parentNodes.some((parentNodeName) => parentNodeName === triggerNode.name);
|
||||
|
||||
return Boolean(isChatChild && (isAgent || isChain || isCustomChainOrAgent));
|
||||
});
|
||||
|
||||
if (!chatNode) {
|
||||
return;
|
||||
}
|
||||
|
||||
connectedNode.value = chatNode;
|
||||
}
|
||||
|
||||
async function convertFileToBinaryData(file: File): Promise<IBinaryData> {
|
||||
const reader = new FileReader();
|
||||
return await new Promise((resolve, reject) => {
|
||||
reader.onload = () => {
|
||||
const binaryData: IBinaryData = {
|
||||
data: (reader.result as string).split('base64,')?.[1] ?? '',
|
||||
mimeType: file.type,
|
||||
fileName: file.name,
|
||||
fileSize: `${file.size} bytes`,
|
||||
fileExtension: file.name.split('.').pop() ?? '',
|
||||
fileType: file.type.split('/')[0] as BinaryFileType,
|
||||
};
|
||||
resolve(binaryData);
|
||||
};
|
||||
reader.onerror = () => {
|
||||
reject(new Error('Failed to convert file to binary data'));
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
async function getKeyedFiles(files: File[]): Promise<IBinaryKeyData> {
|
||||
const binaryData: IBinaryKeyData = {};
|
||||
|
||||
await Promise.all(
|
||||
files.map(async (file, index) => {
|
||||
const data = await convertFileToBinaryData(file);
|
||||
const key = `data${index}`;
|
||||
|
||||
binaryData[key] = data;
|
||||
}),
|
||||
);
|
||||
|
||||
return binaryData;
|
||||
}
|
||||
|
||||
function extractFileMeta(file: File): IDataObject {
|
||||
return {
|
||||
fileName: file.name,
|
||||
fileSize: `${file.size} bytes`,
|
||||
fileExtension: file.name.split('.').pop() ?? '',
|
||||
fileType: file.type.split('/')[0],
|
||||
mimeType: file.type,
|
||||
};
|
||||
}
|
||||
|
||||
async function startWorkflowWithMessage(message: string, files?: File[]): Promise<void> {
|
||||
const triggerNode = chatTrigger.value;
|
||||
|
||||
if (!triggerNode) {
|
||||
showError(new Error('Chat Trigger Node could not be found!'), 'Trigger Node not found');
|
||||
return;
|
||||
}
|
||||
|
||||
let inputKey = 'chatInput';
|
||||
if (triggerNode.type === MANUAL_CHAT_TRIGGER_NODE_TYPE && triggerNode.typeVersion < 1.1) {
|
||||
inputKey = 'input';
|
||||
}
|
||||
if (triggerNode.type === CHAT_TRIGGER_NODE_TYPE) {
|
||||
inputKey = 'chatInput';
|
||||
}
|
||||
|
||||
const usersStore = useUsersStore();
|
||||
const currentUser = usersStore.currentUser ?? ({} as IUser);
|
||||
|
||||
const inputPayload: INodeExecutionData = {
|
||||
json: {
|
||||
sessionId: `test-${currentUser.id || 'unknown'}`,
|
||||
action: 'sendMessage',
|
||||
[inputKey]: message,
|
||||
},
|
||||
};
|
||||
|
||||
if (files && files.length > 0) {
|
||||
const filesMeta = files.map((file) => extractFileMeta(file));
|
||||
const binaryData = await getKeyedFiles(files);
|
||||
|
||||
inputPayload.json.files = filesMeta;
|
||||
inputPayload.binary = binaryData;
|
||||
}
|
||||
const nodeData: ITaskData = {
|
||||
startTime: new Date().getTime(),
|
||||
executionTime: 0,
|
||||
executionStatus: 'success',
|
||||
data: {
|
||||
main: [[inputPayload]],
|
||||
},
|
||||
source: [null],
|
||||
};
|
||||
|
||||
const response = await runWorkflow({
|
||||
triggerNode: triggerNode.name,
|
||||
nodeData,
|
||||
source: 'RunData.ManualChatMessage',
|
||||
});
|
||||
|
||||
workflowsStore.appendChatMessage(message);
|
||||
if (!response) {
|
||||
showError(new Error('It was not possible to start workflow!'), 'Workflow could not be started');
|
||||
return;
|
||||
}
|
||||
|
||||
waitForExecution(response.executionId);
|
||||
}
|
||||
|
||||
function waitForExecution(executionId?: string) {
|
||||
const waitInterval = setInterval(() => {
|
||||
if (!isLoading.value) {
|
||||
clearInterval(waitInterval);
|
||||
|
||||
const lastNodeExecuted =
|
||||
workflowsStore.getWorkflowExecution?.data?.resultData.lastNodeExecuted;
|
||||
|
||||
if (!lastNodeExecuted) return;
|
||||
|
||||
const nodeResponseDataArray =
|
||||
get(workflowsStore.getWorkflowExecution?.data?.resultData.runData, lastNodeExecuted) ?? [];
|
||||
|
||||
const nodeResponseData = nodeResponseDataArray[nodeResponseDataArray.length - 1];
|
||||
|
||||
let responseMessage: string;
|
||||
|
||||
if (get(nodeResponseData, 'error')) {
|
||||
responseMessage = '[ERROR: ' + get(nodeResponseData, 'error.message') + ']';
|
||||
} else {
|
||||
const responseData = get(nodeResponseData, 'data.main[0][0].json');
|
||||
responseMessage = extractResponseMessage(responseData);
|
||||
}
|
||||
|
||||
messages.value.push({
|
||||
text: responseMessage,
|
||||
sender: 'bot',
|
||||
createdAt: new Date().toISOString(),
|
||||
id: executionId ?? uuid(),
|
||||
});
|
||||
|
||||
void nextTick(setNode);
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
|
||||
function extractResponseMessage(responseData?: IDataObject) {
|
||||
if (!responseData || isEmpty(responseData)) {
|
||||
return locale.baseText('chat.window.chat.response.empty');
|
||||
}
|
||||
|
||||
// Paths where the response message might be located
|
||||
const paths = ['output', 'text', 'response.text'];
|
||||
const matchedPath = paths.find((path) => get(responseData, path));
|
||||
|
||||
if (!matchedPath) return JSON.stringify(responseData, null, 2);
|
||||
|
||||
const matchedOutput = get(responseData, matchedPath);
|
||||
if (typeof matchedOutput === 'object') {
|
||||
return '```json\n' + JSON.stringify(matchedOutput, null, 2) + '\n```';
|
||||
}
|
||||
|
||||
return matchedOutput?.toString() ?? '';
|
||||
}
|
||||
|
||||
async function sendMessage(message: string, files?: File[]) {
|
||||
previousMessageIndex.value = 0;
|
||||
if (message.trim() === '' && (!files || files.length === 0)) {
|
||||
showError(
|
||||
new Error(locale.baseText('chat.window.chat.provideMessage')),
|
||||
locale.baseText('chat.window.chat.emptyChatMessage'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const pinnedChatData = usePinnedData(chatTrigger.value);
|
||||
if (pinnedChatData.hasData.value) {
|
||||
const confirmResult = await useMessage().confirm(
|
||||
locale.baseText('chat.window.chat.unpinAndExecute.description'),
|
||||
locale.baseText('chat.window.chat.unpinAndExecute.title'),
|
||||
{
|
||||
confirmButtonText: locale.baseText('chat.window.chat.unpinAndExecute.confirm'),
|
||||
cancelButtonText: locale.baseText('chat.window.chat.unpinAndExecute.cancel'),
|
||||
},
|
||||
);
|
||||
|
||||
if (!(confirmResult === MODAL_CONFIRM)) return;
|
||||
|
||||
pinnedChatData.unsetData('unpin-and-send-chat-message-modal');
|
||||
}
|
||||
|
||||
const newMessage: ChatMessage = {
|
||||
text: message,
|
||||
sender: 'user',
|
||||
createdAt: new Date().toISOString(),
|
||||
id: uuid(),
|
||||
files,
|
||||
};
|
||||
messages.value.push(newMessage);
|
||||
|
||||
await startWorkflowWithMessage(newMessage.text, files);
|
||||
}
|
||||
|
||||
function displayExecution(executionId: string) {
|
||||
const workflow = workflowHelpers.getCurrentWorkflow();
|
||||
const route = router.resolve({
|
||||
name: VIEWS.EXECUTION_PREVIEW,
|
||||
params: { name: workflow.id, executionId },
|
||||
});
|
||||
window.open(route.href, '_blank');
|
||||
}
|
||||
function isTextMessage(message: ChatMessage): message is ChatMessageText {
|
||||
return message.type === 'text' || !message.type;
|
||||
}
|
||||
|
||||
function repostMessage(message: ChatMessageText) {
|
||||
void sendMessage(message.text);
|
||||
}
|
||||
function reuseMessage(message: ChatMessageText) {
|
||||
chatEventBus.emit('setInputValue', message.text);
|
||||
}
|
||||
|
||||
function getChatMessages(): ChatMessageText[] {
|
||||
if (!connectedNode.value) return [];
|
||||
|
||||
const workflow = workflowHelpers.getCurrentWorkflow();
|
||||
const connectedMemoryInputs =
|
||||
workflow.connectionsByDestinationNode[connectedNode.value.name][NodeConnectionType.AiMemory];
|
||||
if (!connectedMemoryInputs) return [];
|
||||
|
||||
const memoryConnection = (connectedMemoryInputs ?? []).find((i) => i.length > 0)?.[0];
|
||||
|
||||
if (!memoryConnection) return [];
|
||||
|
||||
const nodeResultData = workflowsStore.getWorkflowResultDataByNodeName(memoryConnection.node);
|
||||
|
||||
const memoryOutputData = (nodeResultData ?? [])
|
||||
.map((data) => get(data, ['data', NodeConnectionType.AiMemory, 0, 0, 'json']) as MemoryOutput)
|
||||
.find((data) => data.action === 'saveContext');
|
||||
|
||||
return (memoryOutputData?.chatHistory ?? []).map((message, index) => {
|
||||
return {
|
||||
createdAt: new Date().toISOString(),
|
||||
text: message.kwargs.content,
|
||||
id: `preload__${index}`,
|
||||
sender: last(message.id) === 'HumanMessage' ? 'user' : 'bot',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function onArrowKeyDown({ currentInputValue, key }: ArrowKeyDownPayload) {
|
||||
const pastMessages = workflowsStore.getPastChatMessages;
|
||||
const isCurrentInputEmptyOrMatch =
|
||||
currentInputValue.length === 0 || pastMessages.includes(currentInputValue);
|
||||
|
||||
if (isCurrentInputEmptyOrMatch && (key === 'ArrowUp' || key === 'ArrowDown')) {
|
||||
// Blur the input when the user presses the up or down arrow key
|
||||
chatEventBus.emit('blurInput');
|
||||
|
||||
if (pastMessages.length === 1) {
|
||||
previousMessageIndex.value = 0;
|
||||
} else if (key === 'ArrowUp') {
|
||||
previousMessageIndex.value = (previousMessageIndex.value + 1) % pastMessages.length;
|
||||
} else if (key === 'ArrowDown') {
|
||||
previousMessageIndex.value =
|
||||
(previousMessageIndex.value - 1 + pastMessages.length) % pastMessages.length;
|
||||
}
|
||||
|
||||
chatEventBus.emit(
|
||||
'setInputValue',
|
||||
pastMessages[pastMessages.length - 1 - previousMessageIndex.value] ?? '',
|
||||
);
|
||||
|
||||
// Refocus to move the cursor to the end of the input
|
||||
chatEventBus.emit('focusInput');
|
||||
}
|
||||
}
|
||||
|
||||
provide(ChatSymbol, chatConfig);
|
||||
provide(ChatOptionsSymbol, chatOptions);
|
||||
onMounted(() => {
|
||||
getTriggerNode();
|
||||
setConnectedNode();
|
||||
messages.value = getChatMessages();
|
||||
setNode();
|
||||
|
||||
setTimeout(() => chatEventBus.emit('focusInput'), 0);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal
|
||||
:name="WORKFLOW_LM_CHAT_MODAL_KEY"
|
||||
width="80%"
|
||||
max-height="80%"
|
||||
:title="
|
||||
locale.baseText('chat.window.title', {
|
||||
interpolate: {
|
||||
nodeName: connectedNode?.name || locale.baseText('chat.window.noChatNode'),
|
||||
},
|
||||
})
|
||||
"
|
||||
:event-bus="modalBus"
|
||||
:scrollable="false"
|
||||
@keydown.stop
|
||||
>
|
||||
<template #content>
|
||||
<div
|
||||
:class="$style.workflowLmChat"
|
||||
data-test-id="workflow-lm-chat-dialog"
|
||||
:style="messageVars"
|
||||
>
|
||||
<MessagesList :messages="messages" :class="[$style.messages, 'ignore-key-press']">
|
||||
<template #beforeMessage="{ message }">
|
||||
<MessageOptionTooltip
|
||||
v-if="message.sender === 'bot' && !message.id.includes('preload')"
|
||||
placement="right"
|
||||
>
|
||||
{{ locale.baseText('chat.window.chat.chatMessageOptions.executionId') }}:
|
||||
<a href="#" @click="displayExecution(message.id)">{{ message.id }}</a>
|
||||
</MessageOptionTooltip>
|
||||
|
||||
<MessageOptionAction
|
||||
v-if="isTextMessage(message) && message.sender === 'user'"
|
||||
data-test-id="repost-message-button"
|
||||
icon="redo"
|
||||
:label="locale.baseText('chat.window.chat.chatMessageOptions.repostMessage')"
|
||||
placement="left"
|
||||
@click="repostMessage(message)"
|
||||
/>
|
||||
|
||||
<MessageOptionAction
|
||||
v-if="isTextMessage(message) && message.sender === 'user'"
|
||||
data-test-id="reuse-message-button"
|
||||
icon="copy"
|
||||
:label="locale.baseText('chat.window.chat.chatMessageOptions.reuseMessage')"
|
||||
placement="left"
|
||||
@click="reuseMessage(message)"
|
||||
/>
|
||||
</template>
|
||||
</MessagesList>
|
||||
<div v-if="node" :class="$style.logsWrapper" data-test-id="lm-chat-logs">
|
||||
<n8n-text :class="$style.logsTitle" tag="p" size="large">{{
|
||||
locale.baseText('chat.window.logs')
|
||||
}}</n8n-text>
|
||||
<div :class="$style.logs">
|
||||
<LazyRunDataAi :key="messages.length" :node="node" hide-title slim />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<ChatInput
|
||||
:class="$style.messagesInput"
|
||||
data-test-id="lm-chat-inputs"
|
||||
@arrow-key-down="onArrowKeyDown"
|
||||
/>
|
||||
<n8n-info-tip class="mt-s">
|
||||
{{ locale.baseText('chatEmbed.infoTip.description') }}
|
||||
<a @click="uiStore.openModal(CHAT_EMBED_MODAL_KEY)">
|
||||
{{ locale.baseText('chatEmbed.infoTip.link') }}
|
||||
</a>
|
||||
</n8n-info-tip>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.chat-message-markdown ul,
|
||||
.chat-message-markdown ol {
|
||||
padding: 0 0 0 1em;
|
||||
}
|
||||
</style>
|
||||
<style module lang="scss">
|
||||
.no-node-connected {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
.workflowLmChat {
|
||||
--chat--spacing: var(--spacing-m);
|
||||
--chat--message--padding: var(--spacing-xs);
|
||||
|
||||
display: flex;
|
||||
height: 100%;
|
||||
z-index: 9999;
|
||||
min-height: 10rem;
|
||||
|
||||
@media (min-height: 34rem) {
|
||||
min-height: 14.5rem;
|
||||
}
|
||||
@media (min-height: 47rem) {
|
||||
min-height: 25rem;
|
||||
}
|
||||
& ::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
& ::-webkit-scrollbar-thumb {
|
||||
border-radius: var(--border-radius-base);
|
||||
background: var(--color-foreground-dark);
|
||||
border: 1px solid white;
|
||||
}
|
||||
|
||||
& ::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color-foreground-xdark);
|
||||
}
|
||||
}
|
||||
|
||||
.logsWrapper {
|
||||
--node-icon-color: var(--color-text-base);
|
||||
border: 1px solid var(--color-foreground-base);
|
||||
border-radius: var(--border-radius-base);
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
width: 100%;
|
||||
padding: var(--spacing-xs) 0;
|
||||
}
|
||||
|
||||
.logsTitle {
|
||||
margin: 0 var(--spacing-s) var(--spacing-s);
|
||||
}
|
||||
|
||||
.messages {
|
||||
background-color: var(--color-lm-chat-messages-background);
|
||||
border: 1px solid var(--color-foreground-base);
|
||||
border-radius: var(--border-radius-base);
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow: auto;
|
||||
padding-top: 1.5em;
|
||||
|
||||
&:not(:last-child) {
|
||||
margin-right: 1em;
|
||||
}
|
||||
|
||||
& * {
|
||||
font-size: var(--font-size-s);
|
||||
}
|
||||
}
|
||||
.messagesInput {
|
||||
--chat--input--border: var(--input-border-color, var(--border-color-base))
|
||||
var(--input-border-style, var(--border-style-base))
|
||||
var(--input-border-width, var(--border-width-base));
|
||||
|
||||
--chat--input--border-radius: var(--border-radius-base) 0 0 var(--border-radius-base);
|
||||
--chat--input--send--button--background: transparent;
|
||||
--chat--input--send--button--color: var(--color-button-secondary-font);
|
||||
--chat--input--send--button--color-hover: var(--color-primary);
|
||||
--chat--input--border-active: var(--input-focus-border-color, var(--color-secondary));
|
||||
--chat--files-spacing: var(--spacing-2xs) 0;
|
||||
--chat--input--background: var(--color-lm-chat-bot-background);
|
||||
|
||||
[data-theme='dark'] & {
|
||||
--chat--input--text-color: var(--input-font-color, var(--color-text-dark));
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
--chat--input--text-color: var(--input-font-color, var(--color-text-dark));
|
||||
}
|
||||
|
||||
border-bottom-right-radius: var(--border-radius-base);
|
||||
border-top-right-radius: var(--border-radius-base);
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,81 @@
|
|||
<script setup lang="ts">
|
||||
import type { INode } from 'n8n-workflow';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { defineAsyncComponent } from 'vue';
|
||||
|
||||
const LazyRunDataAi = defineAsyncComponent(
|
||||
async () => await import('@/components/RunDataAi/RunDataAi.vue'),
|
||||
);
|
||||
|
||||
defineProps<{
|
||||
node: INode | null;
|
||||
messagesLength: number;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="$style.logsWrapper" data-test-id="lm-chat-logs">
|
||||
<header :class="$style.logsHeader">
|
||||
Latest Logs <span v-if="node">from {{ node?.name }} node</span>
|
||||
</header>
|
||||
<div :class="$style.logs">
|
||||
<LazyRunDataAi
|
||||
v-if="node"
|
||||
:class="$style.runData"
|
||||
:key="messagesLength"
|
||||
:node="node"
|
||||
hide-title
|
||||
slim
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.logsHeader {
|
||||
font-size: var(--font-size-m);
|
||||
font-weight: 400;
|
||||
line-height: 18px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--color-foreground-base);
|
||||
padding: var(--spacing-xs);
|
||||
background-color: var(--color-foreground-xlight);
|
||||
|
||||
> span {
|
||||
font-size: var(--font-size-s);
|
||||
color: var(--color-text-base);
|
||||
}
|
||||
}
|
||||
.logsWrapper {
|
||||
--node-icon-color: var(--color-text-base);
|
||||
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.logsTitle {
|
||||
margin: 0 var(--spacing-s) var(--spacing-s);
|
||||
}
|
||||
.logs {
|
||||
padding: var(--spacing-s) 0;
|
||||
flex-grow: 1;
|
||||
overflow: auto;
|
||||
|
||||
& ::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
& ::-webkit-scrollbar-thumb {
|
||||
border-radius: var(--border-radius-base);
|
||||
background: var(--color-foreground-dark);
|
||||
border: 1px solid white;
|
||||
}
|
||||
|
||||
& ::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color-foreground-xdark);
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,166 @@
|
|||
<script setup lang="ts">
|
||||
import type { ChatMessage, ChatMessageText } from '@n8n/chat/types';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import MessagesList from '@n8n/chat/components/MessagesList.vue';
|
||||
import MessageOptionTooltip from './MessageOptionTooltip.vue';
|
||||
import MessageOptionAction from './MessageOptionAction.vue';
|
||||
import { chatEventBus } from '@n8n/chat/event-buses';
|
||||
import ChatInput from '@n8n/chat/components/Input.vue';
|
||||
|
||||
interface Props {
|
||||
messages: ChatMessage[];
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
const emit = defineEmits<{
|
||||
displayExecution: [id: string];
|
||||
sendMessage: [message: string];
|
||||
}>();
|
||||
|
||||
const locale = useI18n();
|
||||
|
||||
/** Checks if message is a text message */
|
||||
function isTextMessage(message: ChatMessage): message is ChatMessageText {
|
||||
return message.type === 'text' || !message.type;
|
||||
}
|
||||
|
||||
/** Reposts the message */
|
||||
function repostMessage(message: ChatMessageText) {
|
||||
void sendMessage(message.text);
|
||||
}
|
||||
|
||||
/** Sets the message in input for reuse */
|
||||
function reuseMessage(message: ChatMessageText) {
|
||||
chatEventBus.emit('setInputValue', message.text);
|
||||
}
|
||||
|
||||
function sendMessage(message: string) {
|
||||
emit('sendMessage', message);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="$style.chat" data-test-id="workflow-lm-chat-dialog">
|
||||
<header :class="$style.chatHeader">Chat</header>
|
||||
<main :class="$style.chatBody">
|
||||
<MessagesList :messages="messages" :class="[$style.messages, 'ignore-key-press']">
|
||||
<template #beforeMessage="{ message }">
|
||||
<MessageOptionTooltip
|
||||
v-if="message.sender === 'bot' && !message.id.includes('preload')"
|
||||
placement="right"
|
||||
>
|
||||
{{ locale.baseText('chat.window.chat.chatMessageOptions.executionId') }}:
|
||||
<a href="#" @click="emit('displayExecution', message.id)">{{ message.id }}</a>
|
||||
</MessageOptionTooltip>
|
||||
|
||||
<MessageOptionAction
|
||||
v-if="isTextMessage(message) && message.sender === 'user'"
|
||||
data-test-id="repost-message-button"
|
||||
icon="redo"
|
||||
:label="locale.baseText('chat.window.chat.chatMessageOptions.repostMessage')"
|
||||
placement="left"
|
||||
@click="repostMessage(message)"
|
||||
/>
|
||||
|
||||
<MessageOptionAction
|
||||
v-if="isTextMessage(message) && message.sender === 'user'"
|
||||
data-test-id="reuse-message-button"
|
||||
icon="copy"
|
||||
:label="locale.baseText('chat.window.chat.chatMessageOptions.reuseMessage')"
|
||||
placement="left"
|
||||
@click="reuseMessage(message)"
|
||||
/>
|
||||
</template>
|
||||
</MessagesList>
|
||||
</main>
|
||||
<ChatInput :class="$style.messagesInput" data-test-id="lm-chat-inputs" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.chat {
|
||||
--chat--spacing: var(--spacing-xs);
|
||||
--chat--message--padding: var(--spacing-xs);
|
||||
--chat--message--font-size: var(--font-size-2xs);
|
||||
--chat--message--bot--background: transparent;
|
||||
--chat--message--user--background: var(--color-text-lighter);
|
||||
--chat--message--bot--color: var(--color-text-dark);
|
||||
--chat--message--user--color: var(--color-text-dark);
|
||||
--chat--message--bot--border: none;
|
||||
--chat--message--user--border: none;
|
||||
--chat--color-typing: var(--color-text-dark);
|
||||
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background-color: var(--color-background-light);
|
||||
}
|
||||
.chatHeader {
|
||||
font-size: var(--font-size-m);
|
||||
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);
|
||||
}
|
||||
.chatBody {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
|
||||
& ::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
& ::-webkit-scrollbar-thumb {
|
||||
border-radius: var(--border-radius-base);
|
||||
background: var(--color-foreground-dark);
|
||||
border: 1px solid white;
|
||||
}
|
||||
|
||||
& ::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color-foreground-xdark);
|
||||
}
|
||||
}
|
||||
|
||||
.messages {
|
||||
border-radius: var(--border-radius-base);
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow: auto;
|
||||
padding-top: 1.5em;
|
||||
|
||||
&:not(:last-child) {
|
||||
margin-right: 1em;
|
||||
}
|
||||
}
|
||||
|
||||
.messagesInput {
|
||||
--input-border-color: #4538a3;
|
||||
--chat--input--border: var(--input-border-color, var(--border-color-base))
|
||||
var(--input-border-style, var(--border-style-base))
|
||||
var(--input-border-width, var(--border-width-base));
|
||||
|
||||
--chat--input--border-radius: 1.5rem;
|
||||
--chat--input--send--button--background: transparent;
|
||||
--chat--input--send--button--color: var(--color-button-secondary-font);
|
||||
--chat--input--send--button--color-hover: var(--color-primary);
|
||||
--chat--input--border-active: var(--input-focus-border-color, var(--color-secondary));
|
||||
--chat--files-spacing: var(--spacing-2xs) 0;
|
||||
--chat--input--background: var(--color-lm-chat-bot-background);
|
||||
|
||||
[data-theme='dark'] & {
|
||||
--chat--input--text-color: var(--input-font-color, var(--color-text-dark));
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
--chat--input--text-color: var(--input-font-color, var(--color-text-dark));
|
||||
}
|
||||
border-radius: 5rem;
|
||||
padding: var(--chat--spacing);
|
||||
margin-bottom: var(--spacing-4xs);
|
||||
overflow: hidden;
|
||||
flex-grow: 1;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,49 @@
|
|||
<script setup lang="ts">
|
||||
import type { PropType } from 'vue';
|
||||
|
||||
defineProps({
|
||||
/** Tooltip label */
|
||||
label: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
/** Icon name */
|
||||
icon: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
/** Placement of the tooltip */
|
||||
placement: {
|
||||
type: String as PropType<'left' | 'right' | 'top' | 'bottom'>,
|
||||
default: 'top',
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="$style.container">
|
||||
<n8n-tooltip :placement="placement">
|
||||
<template #content>
|
||||
{{ label }}
|
||||
</template>
|
||||
<n8n-icon :class="$style.icon" :icon="icon" size="xsmall" @click="$emit('click')" />
|
||||
</n8n-tooltip>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.container {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin: 0 var(--spacing-4xs);
|
||||
}
|
||||
|
||||
.icon {
|
||||
color: var(--color-foreground-dark);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,41 @@
|
|||
<script setup lang="ts">
|
||||
import type { PropType } from 'vue';
|
||||
|
||||
defineProps({
|
||||
/** Placement of the tooltip */
|
||||
placement: {
|
||||
type: String as PropType<'left' | 'right' | 'top' | 'bottom'>,
|
||||
default: 'top',
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="$style.container">
|
||||
<n8n-tooltip :placement="placement">
|
||||
<template #content>
|
||||
<slot />
|
||||
</template>
|
||||
<span :class="$style.icon">
|
||||
<n8n-icon icon="info" size="xsmall" />
|
||||
</span>
|
||||
</n8n-tooltip>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.container {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin: 0 var(--spacing-4xs);
|
||||
}
|
||||
|
||||
.icon {
|
||||
color: var(--color-foreground-dark);
|
||||
cursor: help;
|
||||
|
||||
&:hover {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,291 @@
|
|||
import type { Ref } from 'vue';
|
||||
import { computed, ref } from 'vue';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import type { ChatMessage, ChatMessageText } from '@n8n/chat/types';
|
||||
import { NodeConnectionType, CHAT_TRIGGER_NODE_TYPE } from 'n8n-workflow';
|
||||
import type {
|
||||
IUser,
|
||||
ITaskData,
|
||||
type INode,
|
||||
type INodeExecutionData,
|
||||
type IBinaryKeyData,
|
||||
type IDataObject,
|
||||
IBinaryData,
|
||||
BinaryFileType,
|
||||
} from 'n8n-workflow';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import { useRunWorkflow } from '@/composables/useRunWorkflow';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
import { useMessage } from '@/composables/useMessage';
|
||||
import { usePinnedData } from '@/composables/usePinnedData';
|
||||
import { useUsersStore } from '@/stores/users.store';
|
||||
import { get, isEmpty, last } from 'lodash-es';
|
||||
import { MANUAL_CHAT_TRIGGER_NODE_TYPE, MODAL_CONFIRM } from '@/constants';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
|
||||
import type { useRouter } from 'vue-router';
|
||||
import type { MemoryOutput } from '../types/chat';
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
|
||||
export function useChatMessaging({
|
||||
router,
|
||||
chatTrigger,
|
||||
messages,
|
||||
}: {
|
||||
router: ReturnType<typeof useRouter>;
|
||||
chatTrigger: Ref<INode | null>;
|
||||
messages: Ref<ChatMessage[]>;
|
||||
}) {
|
||||
const previousMessageIndex = ref(0);
|
||||
const workflowsStore = useWorkflowsStore();
|
||||
const { runWorkflow } = useRunWorkflow({ router });
|
||||
const workflowHelpers = useWorkflowHelpers({ router });
|
||||
const { showError } = useToast();
|
||||
const uiStore = useUIStore();
|
||||
const locale = useI18n();
|
||||
|
||||
const isLoading = computed(() => uiStore.isActionActive.workflowRunning);
|
||||
/** Converts a file to binary data */
|
||||
async function convertFileToBinaryData(file: File): Promise<IBinaryData> {
|
||||
const reader = new FileReader();
|
||||
return await new Promise((resolve, reject) => {
|
||||
reader.onload = () => {
|
||||
const binaryData: IBinaryData = {
|
||||
data: (reader.result as string).split('base64,')?.[1] ?? '',
|
||||
mimeType: file.type,
|
||||
fileName: file.name,
|
||||
fileSize: `${file.size} bytes`,
|
||||
fileExtension: file.name.split('.').pop() ?? '',
|
||||
fileType: file.type.split('/')[0] as BinaryFileType,
|
||||
};
|
||||
resolve(binaryData);
|
||||
};
|
||||
reader.onerror = () => {
|
||||
reject(new Error('Failed to convert file to binary data'));
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
/** Gets keyed files for the workflow input */
|
||||
async function getKeyedFiles(files: File[]): Promise<IBinaryKeyData> {
|
||||
const binaryData: IBinaryKeyData = {};
|
||||
|
||||
await Promise.all(
|
||||
files.map(async (file, index) => {
|
||||
const data = await convertFileToBinaryData(file);
|
||||
const key = `data${index}`;
|
||||
|
||||
binaryData[key] = data;
|
||||
}),
|
||||
);
|
||||
|
||||
return binaryData;
|
||||
}
|
||||
|
||||
/** Extracts file metadata */
|
||||
function extractFileMeta(file: File): IDataObject {
|
||||
return {
|
||||
fileName: file.name,
|
||||
fileSize: `${file.size} bytes`,
|
||||
fileExtension: file.name.split('.').pop() ?? '',
|
||||
fileType: file.type.split('/')[0],
|
||||
mimeType: file.type,
|
||||
};
|
||||
}
|
||||
|
||||
/** Starts workflow execution with the message */
|
||||
async function startWorkflowWithMessage(message: string, files?: File[]): Promise<void> {
|
||||
const triggerNode = chatTrigger.value;
|
||||
|
||||
if (!triggerNode) {
|
||||
showError(new Error('Chat Trigger Node could not be found!'), 'Trigger Node not found');
|
||||
return;
|
||||
}
|
||||
|
||||
let inputKey = 'chatInput';
|
||||
if (triggerNode.type === MANUAL_CHAT_TRIGGER_NODE_TYPE && triggerNode.typeVersion < 1.1) {
|
||||
inputKey = 'input';
|
||||
}
|
||||
if (triggerNode.type === CHAT_TRIGGER_NODE_TYPE) {
|
||||
inputKey = 'chatInput';
|
||||
}
|
||||
|
||||
const usersStore = useUsersStore();
|
||||
const currentUser = usersStore.currentUser ?? ({} as IUser);
|
||||
|
||||
const inputPayload: INodeExecutionData = {
|
||||
json: {
|
||||
sessionId: `test-${currentUser.id || 'unknown'}`,
|
||||
action: 'sendMessage',
|
||||
[inputKey]: message,
|
||||
},
|
||||
};
|
||||
|
||||
if (files && files.length > 0) {
|
||||
const filesMeta = files.map((file) => extractFileMeta(file));
|
||||
const binaryData = await getKeyedFiles(files);
|
||||
|
||||
inputPayload.json.files = filesMeta;
|
||||
inputPayload.binary = binaryData;
|
||||
}
|
||||
const nodeData: ITaskData = {
|
||||
startTime: new Date().getTime(),
|
||||
executionTime: 0,
|
||||
executionStatus: 'success',
|
||||
data: {
|
||||
main: [[inputPayload]],
|
||||
},
|
||||
source: [null],
|
||||
};
|
||||
|
||||
const response = await runWorkflow({
|
||||
triggerNode: triggerNode.name,
|
||||
nodeData,
|
||||
source: 'RunData.ManualChatMessage',
|
||||
});
|
||||
|
||||
workflowsStore.appendChatMessage(message);
|
||||
if (!response?.executionId) {
|
||||
showError(
|
||||
new Error('It was not possible to start workflow!'),
|
||||
'Workflow could not be started',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('DEBUG: Wait for execution');
|
||||
waitForExecution(response.executionId);
|
||||
}
|
||||
|
||||
/** Waits for workflow execution to complete */
|
||||
function waitForExecution(executionId: string) {
|
||||
const waitInterval = setInterval(() => {
|
||||
if (!isLoading.value) {
|
||||
clearInterval(waitInterval);
|
||||
|
||||
const lastNodeExecuted =
|
||||
workflowsStore.getWorkflowExecution?.data?.resultData.lastNodeExecuted;
|
||||
|
||||
if (!lastNodeExecuted) return;
|
||||
|
||||
const nodeResponseDataArray =
|
||||
get(workflowsStore.getWorkflowExecution?.data?.resultData.runData, lastNodeExecuted) ??
|
||||
[];
|
||||
|
||||
const nodeResponseData = nodeResponseDataArray[nodeResponseDataArray.length - 1];
|
||||
|
||||
let responseMessage: string;
|
||||
|
||||
if (get(nodeResponseData, 'error')) {
|
||||
responseMessage = '[ERROR: ' + get(nodeResponseData, 'error.message') + ']';
|
||||
} else {
|
||||
const responseData = get(nodeResponseData, 'data.main[0][0].json');
|
||||
responseMessage = extractResponseMessage(responseData);
|
||||
}
|
||||
|
||||
messages.value.push({
|
||||
text: responseMessage,
|
||||
sender: 'bot',
|
||||
createdAt: new Date().toISOString(),
|
||||
id: executionId ?? uuid(),
|
||||
});
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
|
||||
/** Extracts response message from workflow output */
|
||||
function extractResponseMessage(responseData?: IDataObject) {
|
||||
if (!responseData || isEmpty(responseData)) {
|
||||
return locale.baseText('chat.window.chat.response.empty');
|
||||
}
|
||||
|
||||
// Paths where the response message might be located
|
||||
const paths = ['output', 'text', 'response.text'];
|
||||
const matchedPath = paths.find((path) => get(responseData, path));
|
||||
|
||||
if (!matchedPath) return JSON.stringify(responseData, null, 2);
|
||||
|
||||
const matchedOutput = get(responseData, matchedPath);
|
||||
if (typeof matchedOutput === 'object') {
|
||||
return '```json\n' + JSON.stringify(matchedOutput, null, 2) + '\n```';
|
||||
}
|
||||
|
||||
return matchedOutput?.toString() ?? '';
|
||||
}
|
||||
|
||||
/** Sends a message to the chat */
|
||||
async function sendMessage(message: string, files?: File[]) {
|
||||
previousMessageIndex.value = 0;
|
||||
if (message.trim() === '' && (!files || files.length === 0)) {
|
||||
showError(
|
||||
new Error(locale.baseText('chat.window.chat.provideMessage')),
|
||||
locale.baseText('chat.window.chat.emptyChatMessage'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const pinnedChatData = usePinnedData(chatTrigger.value);
|
||||
if (pinnedChatData.hasData.value) {
|
||||
const confirmResult = await useMessage().confirm(
|
||||
locale.baseText('chat.window.chat.unpinAndExecute.description'),
|
||||
locale.baseText('chat.window.chat.unpinAndExecute.title'),
|
||||
{
|
||||
confirmButtonText: locale.baseText('chat.window.chat.unpinAndExecute.confirm'),
|
||||
cancelButtonText: locale.baseText('chat.window.chat.unpinAndExecute.cancel'),
|
||||
},
|
||||
);
|
||||
|
||||
if (!(confirmResult === MODAL_CONFIRM)) return;
|
||||
|
||||
pinnedChatData.unsetData('unpin-and-send-chat-message-modal');
|
||||
}
|
||||
|
||||
const newMessage: ChatMessage = {
|
||||
text: message,
|
||||
sender: 'user',
|
||||
createdAt: new Date().toISOString(),
|
||||
id: uuid(),
|
||||
files,
|
||||
};
|
||||
messages.value.push(newMessage);
|
||||
|
||||
await startWorkflowWithMessage(newMessage.text, files);
|
||||
}
|
||||
|
||||
function getChatMessages(): ChatMessageText[] {
|
||||
if (!chatTrigger.value) return [];
|
||||
|
||||
const workflow = workflowHelpers.getCurrentWorkflow();
|
||||
const connectedMemoryInputs =
|
||||
workflow.connectionsByDestinationNode[chatTrigger.value.name]?.[NodeConnectionType.AiMemory];
|
||||
if (!connectedMemoryInputs) return [];
|
||||
|
||||
const memoryConnection = (connectedMemoryInputs ?? []).find((i) => i.length > 0)?.[0];
|
||||
|
||||
if (!memoryConnection) return [];
|
||||
|
||||
const nodeResultData = workflowsStore.getWorkflowResultDataByNodeName(memoryConnection.node);
|
||||
|
||||
const memoryOutputData = (nodeResultData ?? [])
|
||||
.map((data) => get(data, ['data', NodeConnectionType.AiMemory, 0, 0, 'json']) as MemoryOutput)
|
||||
.find((data) => data.action === 'saveContext');
|
||||
|
||||
return (memoryOutputData?.chatHistory ?? []).map((message, index) => {
|
||||
return {
|
||||
createdAt: new Date().toISOString(),
|
||||
text: message.kwargs.content,
|
||||
id: `preload__${index}`,
|
||||
sender: last(message.id) === 'HumanMessage' ? 'user' : 'bot',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
previousMessageIndex,
|
||||
sendMessage,
|
||||
extractResponseMessage,
|
||||
waitForExecution,
|
||||
getChatMessages,
|
||||
};
|
||||
}
|
|
@ -0,0 +1,146 @@
|
|||
import type { Ref } from 'vue';
|
||||
import { ref, computed } from 'vue';
|
||||
import {
|
||||
CHAIN_SUMMARIZATION_LANGCHAIN_NODE_TYPE,
|
||||
NodeConnectionType,
|
||||
NodeHelpers,
|
||||
type INode,
|
||||
type INodeParameters,
|
||||
type INodeType,
|
||||
} from 'n8n-workflow';
|
||||
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
|
||||
import {
|
||||
AI_CATEGORY_AGENTS,
|
||||
AI_CATEGORY_CHAINS,
|
||||
AI_CODE_NODE_TYPE,
|
||||
AI_SUBCATEGORY,
|
||||
CHAT_TRIGGER_NODE_TYPE,
|
||||
MANUAL_CHAT_TRIGGER_NODE_TYPE,
|
||||
} from '@/constants';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
import type { INodeUi } from '@/Interface';
|
||||
import type { useRouter } from 'vue-router';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||
|
||||
export function useChatTrigger({ router }: { router: ReturnType<typeof useRouter> }) {
|
||||
const chatTrigger = ref<INode | null>(null);
|
||||
const connectedNode = ref<INode | null>(null);
|
||||
const node = ref<INode | null>(null);
|
||||
const workflowsStore = useWorkflowsStore();
|
||||
const nodeTypesStore = useNodeTypesStore();
|
||||
const workflowHelpers = useWorkflowHelpers({ router });
|
||||
const { showError } = useToast();
|
||||
|
||||
const allowFileUploads = computed(() => {
|
||||
return (chatTrigger.value?.parameters?.options as INodeParameters)?.allowFileUploads === true;
|
||||
});
|
||||
|
||||
const allowedFilesMimeTypes = computed(() => {
|
||||
return (
|
||||
(
|
||||
chatTrigger.value?.parameters?.options as INodeParameters
|
||||
)?.allowedFilesMimeTypes?.toString() ?? ''
|
||||
);
|
||||
});
|
||||
|
||||
/** Gets the chat trigger node from the workflow */
|
||||
function setChatTriggerNode() {
|
||||
const triggerNode = workflowHelpers
|
||||
.getCurrentWorkflow()
|
||||
.queryNodes((nodeType: INodeType) =>
|
||||
[CHAT_TRIGGER_NODE_TYPE, MANUAL_CHAT_TRIGGER_NODE_TYPE].includes(nodeType.description.name),
|
||||
);
|
||||
|
||||
chatTrigger.value = triggerNode[0] || null;
|
||||
}
|
||||
|
||||
/** Sets the connected node after finding the trigger */
|
||||
function setConnectedNode() {
|
||||
const workflow = workflowHelpers.getCurrentWorkflow();
|
||||
console.log('Set connected node');
|
||||
const triggerNode = chatTrigger.value;
|
||||
|
||||
if (!triggerNode) {
|
||||
// showError(new Error('Chat Trigger Node could not be found!'), 'Trigger Node not found');
|
||||
return;
|
||||
}
|
||||
|
||||
const chatNode = workflowsStore.getNodes().find((storeNode: INodeUi): boolean => {
|
||||
if (storeNode.type === CHAIN_SUMMARIZATION_LANGCHAIN_NODE_TYPE) return false;
|
||||
const nodeType = nodeTypesStore.getNodeType(storeNode.type, storeNode.typeVersion);
|
||||
if (!nodeType) return false;
|
||||
|
||||
const isAgent = nodeType.codex?.subcategories?.[AI_SUBCATEGORY]?.includes(AI_CATEGORY_AGENTS);
|
||||
const isChain = nodeType.codex?.subcategories?.[AI_SUBCATEGORY]?.includes(AI_CATEGORY_CHAINS);
|
||||
|
||||
let isCustomChainOrAgent = false;
|
||||
if (nodeType.name === AI_CODE_NODE_TYPE) {
|
||||
const inputs = NodeHelpers.getNodeInputs(workflow, storeNode, nodeType);
|
||||
const inputTypes = NodeHelpers.getConnectionTypes(inputs);
|
||||
|
||||
const outputs = NodeHelpers.getNodeOutputs(workflow, storeNode, nodeType);
|
||||
const outputTypes = NodeHelpers.getConnectionTypes(outputs);
|
||||
|
||||
if (
|
||||
inputTypes.includes(NodeConnectionType.AiLanguageModel) &&
|
||||
inputTypes.includes(NodeConnectionType.Main) &&
|
||||
outputTypes.includes(NodeConnectionType.Main)
|
||||
) {
|
||||
isCustomChainOrAgent = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isAgent && !isChain && !isCustomChainOrAgent) return false;
|
||||
|
||||
const parentNodes = workflow.getParentNodes(storeNode.name);
|
||||
const isChatChild = parentNodes.some((parentNodeName) => parentNodeName === triggerNode.name);
|
||||
|
||||
return Boolean(isChatChild && (isAgent || isChain || isCustomChainOrAgent));
|
||||
});
|
||||
|
||||
console.log('🚀 ~ setConnectedNode ~ chatNode:', chatNode);
|
||||
if (!chatNode) {
|
||||
return;
|
||||
}
|
||||
|
||||
connectedNode.value = chatNode;
|
||||
}
|
||||
|
||||
/** Sets the node that contains metadata */
|
||||
function setLogsSourceNode() {
|
||||
const triggerNode = chatTrigger.value;
|
||||
if (!triggerNode) {
|
||||
return;
|
||||
}
|
||||
|
||||
const workflow = workflowHelpers.getCurrentWorkflow();
|
||||
const childNodes = workflow.getChildNodes(triggerNode.name);
|
||||
|
||||
for (const childNode of childNodes) {
|
||||
// Look for the first connected node with metadata
|
||||
// TODO: Allow later users to change that in the UI
|
||||
const resultData = workflowsStore.getWorkflowResultDataByNodeName(childNode);
|
||||
|
||||
if (!resultData && !Array.isArray(resultData)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (resultData[resultData.length - 1].metadata) {
|
||||
node.value = workflowsStore.getNodeByName(childNode);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
chatTrigger,
|
||||
connectedNode,
|
||||
node,
|
||||
allowFileUploads,
|
||||
allowedFilesMimeTypes,
|
||||
setChatTriggerNode,
|
||||
setConnectedNode,
|
||||
setLogsSourceNode,
|
||||
};
|
||||
}
|
|
@ -0,0 +1,92 @@
|
|||
import type { Ref } from 'vue';
|
||||
import { ref, computed, onMounted, onBeforeUnmount, watchEffect } from 'vue';
|
||||
import type { ResizeData } from 'n8n-design-system/components/N8nResizeWrapper/ResizeWrapper.vue';
|
||||
import { useDebounce } from '@/composables/useDebounce';
|
||||
import type { IChatResizeStyles } from '../types/chat';
|
||||
import { watch } from 'fs';
|
||||
|
||||
export function useResize(container: Ref<HTMLElement | undefined>) {
|
||||
console.log('🚀 ~ useResize ~ container:', container);
|
||||
const chatWidth = ref(0);
|
||||
const maxWidth = ref(0);
|
||||
const minHeight = ref(0);
|
||||
const maxHeight = ref(0);
|
||||
const height = ref(0);
|
||||
|
||||
const rootStyles = computed<IChatResizeStyles>(() => ({
|
||||
'--panel-height': `${height.value}px`,
|
||||
'--chat-width': `${chatWidth.value}px`,
|
||||
}));
|
||||
|
||||
function onResize(data: ResizeData) {
|
||||
if (data.height < minHeight.value) {
|
||||
data.height = minHeight.value;
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.height > maxHeight.value) {
|
||||
data.height = maxHeight.value;
|
||||
return;
|
||||
}
|
||||
height.value = data.height;
|
||||
}
|
||||
|
||||
function onResizeDebounced(data: ResizeData) {
|
||||
void useDebounce().callDebounced(onResize, { debounceTime: 10, trailing: true }, data);
|
||||
}
|
||||
|
||||
function onResizeChat(data: ResizeData) {
|
||||
// Maximum width is 90% of the container width
|
||||
if (data.width > maxWidth.value * 0.9) {
|
||||
data.width = maxWidth.value;
|
||||
return;
|
||||
}
|
||||
|
||||
// Minimum width is 30% of the container width
|
||||
if (data.width < maxWidth.value * 0.3) {
|
||||
data.width = maxWidth.value * 0.2;
|
||||
return;
|
||||
}
|
||||
|
||||
chatWidth.value = data.width;
|
||||
}
|
||||
|
||||
function onWindowResize() {
|
||||
if (container.value) {
|
||||
const { width } = container.value.getBoundingClientRect();
|
||||
maxWidth.value = width;
|
||||
|
||||
if (chatWidth.value === 0) {
|
||||
onResizeChat({ width: maxWidth.value * 0.5 });
|
||||
}
|
||||
}
|
||||
|
||||
// Min height is 30% of the window height
|
||||
minHeight.value = window.innerHeight * 0.2;
|
||||
maxHeight.value = window.innerHeight * 0.75;
|
||||
if (height.value === 0) {
|
||||
onResize({ height: minHeight.value });
|
||||
}
|
||||
}
|
||||
watchEffect(() => {
|
||||
if (container.value) {
|
||||
onWindowResize();
|
||||
}
|
||||
});
|
||||
onMounted(() => {
|
||||
window.addEventListener('resize', onWindowResize);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', onWindowResize);
|
||||
});
|
||||
|
||||
return {
|
||||
height,
|
||||
chatWidth,
|
||||
rootStyles,
|
||||
onResize,
|
||||
onResizeDebounced,
|
||||
onResizeChat,
|
||||
};
|
||||
}
|
22
packages/editor-ui/src/components/CanvasChat/types/chat.ts
Normal file
22
packages/editor-ui/src/components/CanvasChat/types/chat.ts
Normal file
|
@ -0,0 +1,22 @@
|
|||
export interface LangChainMessage {
|
||||
id: string[];
|
||||
kwargs: {
|
||||
content: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface MemoryOutput {
|
||||
action: string;
|
||||
chatHistory?: LangChainMessage[];
|
||||
}
|
||||
|
||||
export interface IChatMessageResponse {
|
||||
executionId?: string;
|
||||
success: boolean;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
export interface IChatResizeStyles {
|
||||
'--panel-height': string;
|
||||
'--chat-width': string;
|
||||
}
|
|
@ -1,7 +1,7 @@
|
|||
<script lang="ts" setup>
|
||||
import type { IAiDataContent } from '@/Interface';
|
||||
import { capitalize } from 'lodash-es';
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { ref, onMounted, Ref } from 'vue';
|
||||
import type { ParsedAiContent } from './useAiContentParsers';
|
||||
import { useAiContentParsers } from './useAiContentParsers';
|
||||
import VueMarkdown from 'vue-markdown-render';
|
||||
|
@ -24,7 +24,7 @@ const contentParsers = useAiContentParsers();
|
|||
|
||||
// eslint-disable-next-line @typescript-eslint/no-use-before-define
|
||||
const isExpanded = ref(getInitialExpandedState());
|
||||
const isShowRaw = ref(false);
|
||||
const renderType = ref<'rendered' | 'json'>('rendered');
|
||||
const contentParsed = ref(false);
|
||||
const parsedRun = ref(undefined as ParsedAiContent | undefined);
|
||||
function getInitialExpandedState() {
|
||||
|
@ -134,6 +134,10 @@ function onCopyToClipboard(content: IDataObject | IDataObject[]) {
|
|||
} catch (err) {}
|
||||
}
|
||||
|
||||
function onRenderTypeChange(value: 'rendered' | 'json') {
|
||||
renderType.value = value;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
parsedRun.value = parseAiRunData(props.runData);
|
||||
if (parsedRun.value) {
|
||||
|
@ -149,13 +153,16 @@ onMounted(() => {
|
|||
<font-awesome-icon :icon="isExpanded ? 'angle-down' : 'angle-up'" size="lg" />
|
||||
</button>
|
||||
<p :class="$style.blockTitle">{{ capitalize(runData.inOut) }}</p>
|
||||
<!-- @click.stop to prevent event from bubbling to blockHeader and toggling expanded state when clicking on rawSwitch -->
|
||||
<el-switch
|
||||
v-if="contentParsed && !error"
|
||||
v-model="isShowRaw"
|
||||
<n8n-radio-buttons
|
||||
v-if="contentParsed && !error && isExpanded"
|
||||
size="small"
|
||||
:model-value="renderType"
|
||||
:class="$style.rawSwitch"
|
||||
active-text="RAW JSON"
|
||||
@click.stop
|
||||
:options="[
|
||||
{ label: 'Rendered', value: 'rendered' },
|
||||
{ label: 'JSON', value: 'json' },
|
||||
]"
|
||||
@update:model-value="onRenderTypeChange"
|
||||
/>
|
||||
</header>
|
||||
<main
|
||||
|
@ -172,7 +179,7 @@ onMounted(() => {
|
|||
:class="$style.contentText"
|
||||
:data-content-type="parsedContent?.type"
|
||||
>
|
||||
<template v-if="parsedContent && !isShowRaw">
|
||||
<template v-if="parsedContent && renderType === 'rendered'">
|
||||
<template v-if="parsedContent.type === 'json'">
|
||||
<VueMarkdown
|
||||
:source="jsonToMarkdown(parsedContent.data as JsonMarkdown)"
|
||||
|
@ -252,17 +259,18 @@ onMounted(() => {
|
|||
}
|
||||
.contentText {
|
||||
padding-top: var(--spacing-s);
|
||||
font-size: var(--font-size-xs);
|
||||
padding-left: var(--spacing-m);
|
||||
font-size: var(--font-size-m);
|
||||
// max-height: 100%;
|
||||
}
|
||||
.block {
|
||||
border: 1px solid var(--color-foreground-base);
|
||||
background: var(--color-background-xlight);
|
||||
padding: var(--spacing-xs);
|
||||
border-radius: 4px;
|
||||
margin-bottom: var(--spacing-2xs);
|
||||
// border: 1px solid var(--color-foreground-base);
|
||||
// background: var(--color-background-xlight);
|
||||
// padding: var(--spacing-xs);
|
||||
// border-radius: 4px;
|
||||
margin-top: var(--spacing-xl);
|
||||
}
|
||||
.blockContent {
|
||||
:root .blockContent {
|
||||
height: 0;
|
||||
overflow: hidden;
|
||||
|
||||
|
@ -271,15 +279,11 @@ onMounted(() => {
|
|||
}
|
||||
}
|
||||
.runText {
|
||||
line-height: var(--font-line-height-regular);
|
||||
line-height: var(--font-line-height-xloose);
|
||||
white-space: pre-line;
|
||||
}
|
||||
.rawSwitch {
|
||||
margin-left: auto;
|
||||
|
||||
& * {
|
||||
font-size: var(--font-size-2xs);
|
||||
}
|
||||
height: fit-content;
|
||||
}
|
||||
.blockHeader {
|
||||
display: flex;
|
||||
|
@ -287,15 +291,20 @@ onMounted(() => {
|
|||
cursor: pointer;
|
||||
/* This hack is needed to make the whole surface of header clickable */
|
||||
margin: calc(-1 * var(--spacing-xs));
|
||||
padding: var(--spacing-xs);
|
||||
padding: var(--spacing-4xs) var(--spacing-xs);
|
||||
align-items: center;
|
||||
|
||||
& * {
|
||||
user-select: none;
|
||||
}
|
||||
}
|
||||
.blockTitle {
|
||||
font-size: var(--font-size-2xs);
|
||||
font-size: var(--font-size-m);
|
||||
color: var(--color-text-dark);
|
||||
font-weight: var(--font-weight-bold);
|
||||
margin: 0;
|
||||
// Visually center the title
|
||||
padding-bottom: var(--spacing-4xs);
|
||||
}
|
||||
.blockToggle {
|
||||
border: none;
|
||||
|
|
|
@ -238,7 +238,11 @@ watch(() => props.runIndex, selectFirst, { immediate: true });
|
|||
{{ node.label }}
|
||||
</template>
|
||||
<span :class="$style.leafLabel">
|
||||
<NodeIcon :node-type="getNodeType(data.node)!" :size="17" />
|
||||
<NodeIcon
|
||||
:node-type="getNodeType(data.node)!"
|
||||
:size="17"
|
||||
:class="$style.nodeIcon"
|
||||
/>
|
||||
<span v-if="!slim" v-text="node.label" />
|
||||
</span>
|
||||
</n8n-tooltip>
|
||||
|
@ -293,7 +297,7 @@ watch(() => props.runIndex, selectFirst, { immediate: true });
|
|||
flex-shrink: 0;
|
||||
min-width: 12.8rem;
|
||||
height: 100%;
|
||||
border-right: 1px solid var(--color-foreground-base);
|
||||
|
||||
padding-right: var(--spacing-xs);
|
||||
padding-left: var(--spacing-2xs);
|
||||
&.slim {
|
||||
|
@ -332,20 +336,31 @@ watch(() => props.runIndex, selectFirst, { immediate: true });
|
|||
margin-left: var(--spacing-xs);
|
||||
}
|
||||
}
|
||||
.nodeIcon {
|
||||
padding: var(--spacing-3xs) var(--spacing-3xs);
|
||||
border-radius: var(--border-radius-base);
|
||||
margin-right: var(--spacing-4xs);
|
||||
}
|
||||
.isSelected {
|
||||
background-color: var(--color-foreground-base);
|
||||
.nodeIcon {
|
||||
background-color: var(--color-foreground-base);
|
||||
}
|
||||
}
|
||||
.treeNode {
|
||||
display: inline-flex;
|
||||
border-radius: var(--border-radius-base);
|
||||
align-items: center;
|
||||
gap: var(--spacing-3xs);
|
||||
padding: var(--spacing-4xs) var(--spacing-3xs);
|
||||
font-size: var(--font-size-xs);
|
||||
// gap: var(--spacing-3xs);
|
||||
padding-right: var(--spacing-3xs);
|
||||
margin: var(--spacing-4xs) 0;
|
||||
font-size: var(--font-size-2xs);
|
||||
color: var(--color-text-dark);
|
||||
margin-bottom: var(--spacing-3xs);
|
||||
cursor: pointer;
|
||||
|
||||
&.isSelected {
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
&:hover {
|
||||
background-color: var(--color-foreground-base);
|
||||
}
|
||||
|
@ -361,6 +376,7 @@ watch(() => props.runIndex, selectFirst, { immediate: true });
|
|||
height: 0.125rem;
|
||||
left: 0.75rem;
|
||||
width: calc(var(--item-depth) * 0.625rem);
|
||||
margin-top: var(--spacing-3xs);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -57,6 +57,7 @@ export class ExecuteCommand implements INodeType {
|
|||
},
|
||||
inputs: [NodeConnectionType.Main],
|
||||
outputs: [NodeConnectionType.Main],
|
||||
usableAsTool: true,
|
||||
properties: [
|
||||
{
|
||||
displayName: 'Execute Once',
|
||||
|
|
Loading…
Reference in a new issue