feat(core): Add support for building LLM applications (#7235)
This extracts all core and editor changes from #7246 and #7137, so that we can get these changes merged first. ADO-1120 [DB Tests](https://github.com/n8n-io/n8n/actions/runs/6379749011) [E2E Tests](https://github.com/n8n-io/n8n/actions/runs/6379751480) [Workflow Tests](https://github.com/n8n-io/n8n/actions/runs/6379752828) --------- Co-authored-by: Jan Oberhauser <jan.oberhauser@gmail.com> Co-authored-by: Oleg Ivaniv <me@olegivaniv.com> Co-authored-by: Alex Grozav <alex@grozav.com> Co-authored-by: कारतोफ्फेलस्क्रिप्ट™ <aditya@netroy.in>
This commit is contained in:
committed by
GitHub
parent
04dfcd73be
commit
00a4b8b0c6
608
packages/editor-ui/src/components/WorkflowLMChat.vue
Normal file
608
packages/editor-ui/src/components/WorkflowLMChat.vue
Normal file
@@ -0,0 +1,608 @@
|
||||
<template>
|
||||
<Modal
|
||||
:name="WORKFLOW_LM_CHAT_MODAL_KEY"
|
||||
width="80%"
|
||||
maxHeight="80%"
|
||||
:title="
|
||||
$locale.baseText('chat.window.title', {
|
||||
interpolate: {
|
||||
nodeName: connectedNode?.name || $locale.baseText('chat.window.noChatNode'),
|
||||
},
|
||||
})
|
||||
"
|
||||
:eventBus="modalBus"
|
||||
:scrollable="false"
|
||||
@keydown.stop
|
||||
>
|
||||
<template #content>
|
||||
<div v-loading="isLoading" class="workflow-lm-chat" data-test-id="workflow-lm-chat-dialog">
|
||||
<div class="messages ignore-key-press" ref="messagesContainer">
|
||||
<div
|
||||
v-for="message in messages"
|
||||
:key="`${message.executionId}__${message.sender}`"
|
||||
:class="['message', message.sender]"
|
||||
>
|
||||
<div :class="['content', message.sender]">
|
||||
{{ message.text }}
|
||||
|
||||
<div class="message-options no-select-on-click">
|
||||
<n8n-info-tip
|
||||
type="tooltip"
|
||||
theme="info-light"
|
||||
tooltipPlacement="right"
|
||||
v-if="message.sender === 'bot'"
|
||||
>
|
||||
<div v-if="message.executionId">
|
||||
<n8n-text :bold="true" size="small">
|
||||
<span @click.stop="displayExecution(message.executionId)">
|
||||
{{ $locale.baseText('chat.window.chat.chatMessageOptions.executionId') }}:
|
||||
<a href="#" class="link">{{ message.executionId }}</a>
|
||||
</span>
|
||||
</n8n-text>
|
||||
</div>
|
||||
</n8n-info-tip>
|
||||
|
||||
<div
|
||||
@click="repostMessage(message)"
|
||||
class="option"
|
||||
:title="$locale.baseText('chat.window.chat.chatMessageOptions.repostMessage')"
|
||||
data-test-id="repost-message-button"
|
||||
v-if="message.sender === 'user'"
|
||||
>
|
||||
<font-awesome-icon icon="redo" />
|
||||
</div>
|
||||
<div
|
||||
@click="reuseMessage(message)"
|
||||
class="option"
|
||||
:title="$locale.baseText('chat.window.chat.chatMessageOptions.reuseMessage')"
|
||||
data-test-id="reuse-message-button"
|
||||
v-if="message.sender === 'user'"
|
||||
>
|
||||
<font-awesome-icon icon="copy" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="logs-wrapper">
|
||||
<n8n-text class="logs-title" tag="p" size="large">{{
|
||||
$locale.baseText('chat.window.logs')
|
||||
}}</n8n-text>
|
||||
<div class="logs">
|
||||
<run-data-ai v-if="node" :node="node" hide-title slim :key="messages.length" />
|
||||
<div v-else class="no-node-connected">
|
||||
<n8n-text tag="div" :bold="true" color="text-dark" size="large">{{
|
||||
$locale.baseText('chat.window.noExecution')
|
||||
}}</n8n-text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div class="workflow-lm-chat-footer">
|
||||
<n8n-input
|
||||
v-model="currentMessage"
|
||||
class="message-input"
|
||||
type="textarea"
|
||||
ref="inputField"
|
||||
:placeholder="$locale.baseText('chat.window.chat.placeholder')"
|
||||
@keydown.stop="updated"
|
||||
/>
|
||||
<n8n-button
|
||||
@click.stop="sendChatMessage(currentMessage)"
|
||||
class="send-button"
|
||||
:loading="isLoading"
|
||||
:label="$locale.baseText('chat.window.chat.sendButtonText')"
|
||||
size="large"
|
||||
icon="comment"
|
||||
type="primary"
|
||||
data-test-id="workflow-chat-button"
|
||||
/>
|
||||
|
||||
<n8n-info-tip class="mt-s">
|
||||
{{ $locale.baseText('chatEmbed.infoTip.description') }}
|
||||
<a @click="openChatEmbedModal">
|
||||
{{ $locale.baseText('chatEmbed.infoTip.link') }}
|
||||
</a>
|
||||
</n8n-info-tip>
|
||||
</div>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineAsyncComponent, defineComponent } from 'vue';
|
||||
import { mapStores } from 'pinia';
|
||||
|
||||
import { useToast } from '@/composables';
|
||||
import Modal from '@/components/Modal.vue';
|
||||
import {
|
||||
AI_CATEGORY_AGENTS,
|
||||
AI_CATEGORY_CHAINS,
|
||||
AI_CODE_NODE_TYPE,
|
||||
AI_SUBCATEGORY,
|
||||
CHAT_EMBED_MODAL_KEY,
|
||||
MANUAL_CHAT_TRIGGER_NODE_TYPE,
|
||||
VIEWS,
|
||||
WORKFLOW_LM_CHAT_MODAL_KEY,
|
||||
} from '@/constants';
|
||||
|
||||
import { workflowRun } from '@/mixins/workflowRun';
|
||||
import { get, last } from 'lodash-es';
|
||||
|
||||
import { useUIStore, useWorkflowsStore } from '@/stores';
|
||||
import { createEventBus } from 'n8n-design-system/utils';
|
||||
import {
|
||||
type INode,
|
||||
type INodeType,
|
||||
type ITaskData,
|
||||
NodeHelpers,
|
||||
NodeConnectionType,
|
||||
} from 'n8n-workflow';
|
||||
import type { INodeUi } from '@/Interface';
|
||||
|
||||
const RunDataAi = defineAsyncComponent(async () => import('@/components/RunDataAi/RunDataAi.vue'));
|
||||
|
||||
interface ChatMessage {
|
||||
text: string;
|
||||
sender: 'bot' | 'user';
|
||||
executionId?: string;
|
||||
}
|
||||
|
||||
// TODO: Add proper type
|
||||
interface LangChainMessage {
|
||||
id: string[];
|
||||
kwargs: {
|
||||
content: string;
|
||||
};
|
||||
}
|
||||
|
||||
// TODO:
|
||||
// - display additional information like execution time, tokens used, ...
|
||||
// - display errors better
|
||||
export default defineComponent({
|
||||
name: 'WorkflowLMChat',
|
||||
mixins: [workflowRun],
|
||||
components: {
|
||||
Modal,
|
||||
RunDataAi,
|
||||
},
|
||||
setup(props) {
|
||||
return {
|
||||
...useToast(),
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
||||
...workflowRun.setup?.(props),
|
||||
};
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
connectedNode: null as INodeUi | null,
|
||||
currentMessage: '',
|
||||
messages: [] as ChatMessage[],
|
||||
modalBus: createEventBus(),
|
||||
node: null as INodeUi | null,
|
||||
WORKFLOW_LM_CHAT_MODAL_KEY,
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
...mapStores(useWorkflowsStore, useUIStore),
|
||||
isLoading(): boolean {
|
||||
return this.uiStore.isActionActive('workflowRunning');
|
||||
},
|
||||
},
|
||||
async mounted() {
|
||||
this.setConnectedNode();
|
||||
this.messages = this.getChatMessages();
|
||||
this.setNode();
|
||||
},
|
||||
methods: {
|
||||
displayExecution(executionId: string) {
|
||||
const workflow = this.getCurrentWorkflow();
|
||||
const route = this.$router.resolve({
|
||||
name: VIEWS.EXECUTION_PREVIEW,
|
||||
params: { name: workflow.id, executionId },
|
||||
});
|
||||
window.open(route.href, '_blank');
|
||||
},
|
||||
repostMessage(message: ChatMessage) {
|
||||
void this.sendChatMessage(message.text);
|
||||
},
|
||||
reuseMessage(message: ChatMessage) {
|
||||
this.currentMessage = message.text;
|
||||
const inputField = this.$refs.inputField as HTMLInputElement;
|
||||
inputField.focus();
|
||||
},
|
||||
updated(event: KeyboardEvent) {
|
||||
if (event.key === 'Enter' && !event.shiftKey && this.currentMessage) {
|
||||
void this.sendChatMessage(this.currentMessage);
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
}
|
||||
},
|
||||
async sendChatMessage(message: string) {
|
||||
this.messages.push({
|
||||
text: message,
|
||||
sender: 'user',
|
||||
} as ChatMessage);
|
||||
|
||||
this.currentMessage = '';
|
||||
|
||||
await this.startWorkflowWithMessage(message);
|
||||
|
||||
// Scroll to bottom
|
||||
const containerRef = this.$refs.messagesContainer as HTMLElement | undefined;
|
||||
if (containerRef) {
|
||||
// Wait till message got added else it will not scroll correctly
|
||||
await this.$nextTick();
|
||||
containerRef.scrollTo({
|
||||
top: containerRef.scrollHeight,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
setConnectedNode() {
|
||||
const workflow = this.getCurrentWorkflow();
|
||||
const triggerNode = workflow.queryNodes(
|
||||
(nodeType: INodeType) => nodeType.description.name === MANUAL_CHAT_TRIGGER_NODE_TYPE,
|
||||
);
|
||||
|
||||
if (!triggerNode.length) {
|
||||
this.showError(
|
||||
new Error('Chat Trigger Node could not be found!'),
|
||||
'Trigger Node not found',
|
||||
);
|
||||
return;
|
||||
}
|
||||
const chatNode = this.workflowsStore.getNodes().find((node: INodeUi): boolean => {
|
||||
const nodeType = this.nodeTypesStore.getNodeType(node.type, node.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, node, nodeType);
|
||||
const inputTypes = NodeHelpers.getConnectionTypes(inputs);
|
||||
|
||||
const outputs = NodeHelpers.getNodeOutputs(workflow, node, 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(node.name);
|
||||
const isChatChild = parentNodes.some(
|
||||
(parentNodeName) => parentNodeName === triggerNode[0].name,
|
||||
);
|
||||
|
||||
return Boolean(isChatChild && (isAgent || isChain || isCustomChainOrAgent));
|
||||
});
|
||||
|
||||
if (!chatNode) {
|
||||
this.showError(
|
||||
new Error('Chat viable node(Agent or Chain) could not be found!'),
|
||||
'Chat node not found',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.connectedNode = chatNode;
|
||||
},
|
||||
getChatMessages(): ChatMessage[] {
|
||||
if (!this.connectedNode) return [];
|
||||
|
||||
const workflow = this.getCurrentWorkflow();
|
||||
const connectedMemoryInputs =
|
||||
workflow.connectionsByDestinationNode[this.connectedNode.name]?.memory;
|
||||
if (!connectedMemoryInputs) return [];
|
||||
|
||||
const memoryConnection = (connectedMemoryInputs ?? []).find((i) => i.length > 0)?.[0];
|
||||
|
||||
if (!memoryConnection) return [];
|
||||
|
||||
const nodeResultData = this.workflowsStore?.getWorkflowResultDataByNodeName(
|
||||
memoryConnection.node,
|
||||
);
|
||||
|
||||
const memoryOutputData = nodeResultData
|
||||
?.map(
|
||||
(
|
||||
data,
|
||||
): {
|
||||
action: string;
|
||||
chatHistory?: unknown[];
|
||||
response?: {
|
||||
chat_history?: unknown[];
|
||||
};
|
||||
} => get(data, 'data.memory.0.0.json')!,
|
||||
)
|
||||
?.find((data) =>
|
||||
['chatHistory', 'loadMemoryVariables'].includes(data?.action) ? data : undefined,
|
||||
);
|
||||
|
||||
let chatHistory: LangChainMessage[];
|
||||
if (memoryOutputData?.chatHistory) {
|
||||
chatHistory = memoryOutputData?.chatHistory as LangChainMessage[];
|
||||
} else if (memoryOutputData?.response) {
|
||||
chatHistory = memoryOutputData?.response.chat_history as LangChainMessage[];
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
|
||||
return chatHistory.map((message) => {
|
||||
return {
|
||||
text: message.kwargs.content,
|
||||
sender: last(message.id) === 'HumanMessage' ? 'user' : 'bot',
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
setNode(): void {
|
||||
const triggerNode = this.getTriggerNode();
|
||||
if (!triggerNode) {
|
||||
return;
|
||||
}
|
||||
|
||||
const workflow = this.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 = this.workflowsStore.getWorkflowResultDataByNodeName(childNode);
|
||||
|
||||
if (!resultData && !Array.isArray(resultData)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (resultData[resultData.length - 1].metadata) {
|
||||
this.node = this.workflowsStore.getNodeByName(childNode);
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
getTriggerNode(): INode | null {
|
||||
const workflow = this.getCurrentWorkflow();
|
||||
const triggerNode = workflow.queryNodes(
|
||||
(nodeType: INodeType) => nodeType.description.name === MANUAL_CHAT_TRIGGER_NODE_TYPE,
|
||||
);
|
||||
|
||||
if (!triggerNode.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return triggerNode[0];
|
||||
},
|
||||
async startWorkflowWithMessage(message: string): Promise<void> {
|
||||
const triggerNode = this.getTriggerNode();
|
||||
|
||||
if (!triggerNode) {
|
||||
this.showError(
|
||||
new Error('Chat Trigger Node could not be found!'),
|
||||
'Trigger Node not found',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const nodeData: ITaskData = {
|
||||
startTime: new Date().getTime(),
|
||||
executionTime: 0,
|
||||
executionStatus: 'success',
|
||||
data: {
|
||||
main: [
|
||||
[
|
||||
{
|
||||
json: {
|
||||
input: message,
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
source: [null],
|
||||
};
|
||||
|
||||
const response = await this.runWorkflow({
|
||||
triggerNode: triggerNode.name,
|
||||
nodeData,
|
||||
source: 'RunData.ManualChatMessage',
|
||||
});
|
||||
|
||||
if (!response) {
|
||||
this.showError(
|
||||
new Error('It was not possible to start workflow!'),
|
||||
'Workflow could not be started',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.waitForExecution(response.executionId);
|
||||
},
|
||||
waitForExecution(executionId?: string) {
|
||||
const that = this;
|
||||
const waitInterval = setInterval(() => {
|
||||
if (!that.isLoading) {
|
||||
clearInterval(waitInterval);
|
||||
|
||||
const lastNodeExecuted =
|
||||
this.workflowsStore.getWorkflowExecution?.data?.resultData.lastNodeExecuted;
|
||||
|
||||
const nodeResponseDataArray = get(
|
||||
this.workflowsStore.getWorkflowExecution?.data?.resultData.runData,
|
||||
`[${lastNodeExecuted}]`,
|
||||
) as ITaskData[];
|
||||
|
||||
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');
|
||||
if (responseData) {
|
||||
const responseObj = responseData as object & { output?: string };
|
||||
if (responseObj.output !== undefined) {
|
||||
responseMessage = responseObj.output;
|
||||
} else if (Object.keys(responseObj).length === 0) {
|
||||
responseMessage = '<NO RESPONSE FOUND>';
|
||||
} else {
|
||||
responseMessage = JSON.stringify(responseObj, null, 2);
|
||||
}
|
||||
} else {
|
||||
responseMessage = '<NO RESPONSE FOUND>';
|
||||
}
|
||||
}
|
||||
|
||||
this.messages.push({
|
||||
text: responseMessage,
|
||||
sender: 'bot',
|
||||
executionId,
|
||||
} as ChatMessage);
|
||||
|
||||
void this.$nextTick(() => {
|
||||
that.setNode();
|
||||
});
|
||||
}
|
||||
}, 500);
|
||||
},
|
||||
closeDialog() {
|
||||
this.modalBus.emit('close');
|
||||
void this.$externalHooks().run('workflowSettings.dialogVisibleChanged', {
|
||||
dialogVisible: false,
|
||||
});
|
||||
},
|
||||
openChatEmbedModal() {
|
||||
this.uiStore.openModal(CHAT_EMBED_MODAL_KEY);
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.no-node-connected {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
.workflow-lm-chat {
|
||||
color: $custom-font-black;
|
||||
font-size: var(--font-size-s);
|
||||
display: flex;
|
||||
height: 100%;
|
||||
min-height: 400px;
|
||||
|
||||
.logs-wrapper {
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 4px;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
width: 100%;
|
||||
padding: var(--spacing-xs) 0;
|
||||
|
||||
.logs-title {
|
||||
margin: 0 var(--spacing-s) var(--spacing-s);
|
||||
}
|
||||
}
|
||||
.messages {
|
||||
background-color: var(--color-background-base);
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 4px;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow: hidden auto;
|
||||
padding-top: 1.5em;
|
||||
margin-right: 1em;
|
||||
|
||||
.message {
|
||||
float: left;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
.content {
|
||||
border-radius: 10px;
|
||||
line-height: 1.5;
|
||||
margin: 0.5em 1em;
|
||||
max-width: 75%;
|
||||
padding: 1em;
|
||||
white-space: pre-wrap;
|
||||
overflow-x: auto;
|
||||
|
||||
&.bot {
|
||||
background-color: #e0d0d0;
|
||||
float: left;
|
||||
|
||||
.message-options {
|
||||
left: 1.5em;
|
||||
}
|
||||
}
|
||||
|
||||
&.user {
|
||||
background-color: #d0e0d0;
|
||||
float: right;
|
||||
text-align: right;
|
||||
|
||||
.message-options {
|
||||
right: 1.5em;
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
.message-options {
|
||||
color: #aaa;
|
||||
display: none;
|
||||
font-size: 0.9em;
|
||||
height: 26px;
|
||||
position: absolute;
|
||||
text-align: left;
|
||||
top: -1.2em;
|
||||
width: 120px;
|
||||
z-index: 10;
|
||||
|
||||
.option {
|
||||
cursor: pointer;
|
||||
display: inline-block;
|
||||
width: 28px;
|
||||
}
|
||||
|
||||
.link {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.message-options {
|
||||
display: initial;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.workflow-lm-chat-footer {
|
||||
.message-input {
|
||||
width: calc(100% - 8em);
|
||||
}
|
||||
.send-button {
|
||||
float: right;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user