Files
n8n-enterprise-unlocked/packages/editor-ui/src/components/CanvasChat/composables/useChatMessaging.ts

287 lines
8.3 KiB
TypeScript

import type { ComputedRef, 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 {
ITaskData,
INodeExecutionData,
IBinaryKeyData,
IDataObject,
IBinaryData,
BinaryFileType,
Workflow,
IRunExecutionData,
} from 'n8n-workflow';
import { useToast } from '@/composables/useToast';
import { useMessage } from '@/composables/useMessage';
import { usePinnedData } from '@/composables/usePinnedData';
import { get, isEmpty, last } from 'lodash-es';
import { MANUAL_CHAT_TRIGGER_NODE_TYPE, MODAL_CONFIRM } from '@/constants';
import { useI18n } from '@/composables/useI18n';
import type { MemoryOutput } from '../types/chat';
import type { IExecutionPushResponse, INodeUi } from '@/Interface';
export type RunWorkflowChatPayload = {
triggerNode: string;
nodeData: ITaskData;
source: string;
message: string;
};
export interface ChatMessagingDependencies {
chatTrigger: Ref<INodeUi | null>;
connectedNode: Ref<INodeUi | null>;
messages: Ref<ChatMessage[]>;
sessionId: Ref<string>;
workflow: ComputedRef<Workflow>;
executionResultData: ComputedRef<IRunExecutionData['resultData'] | undefined>;
getWorkflowResultDataByNodeName: (nodeName: string) => ITaskData[] | null;
onRunChatWorkflow: (
payload: RunWorkflowChatPayload,
) => Promise<IExecutionPushResponse | undefined>;
}
export function useChatMessaging({
chatTrigger,
connectedNode,
messages,
sessionId,
workflow,
executionResultData,
getWorkflowResultDataByNodeName,
onRunChatWorkflow,
}: ChatMessagingDependencies) {
const locale = useI18n();
const { showError } = useToast();
const previousMessageIndex = ref(0);
const isLoading = ref(false);
/** 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 inputPayload: INodeExecutionData = {
json: {
sessionId: sessionId.value,
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],
};
isLoading.value = true;
const response = await onRunChatWorkflow({
triggerNode: triggerNode.name,
nodeData,
source: 'RunData.ManualChatMessage',
message,
});
isLoading.value = false;
if (!response?.executionId) {
return;
}
processExecutionResultData(response.executionId);
}
function processExecutionResultData(executionId: string) {
const lastNodeExecuted = executionResultData.value?.lastNodeExecuted;
if (!lastNodeExecuted) return;
const nodeResponseDataArray = get(executionResultData.value.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);
}
isLoading.value = false;
messages.value.push({
text: responseMessage,
sender: 'bot',
createdAt: new Date().toISOString(),
id: executionId ?? uuid(),
});
}
/** 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 & { sessionId: string } = {
text: message,
sender: 'user',
createdAt: new Date().toISOString(),
sessionId: sessionId.value,
id: uuid(),
files,
};
messages.value.push(newMessage);
await startWorkflowWithMessage(newMessage.text, files);
}
function getChatMessages(): ChatMessageText[] {
if (!connectedNode.value) return [];
const connectedMemoryInputs =
workflow.value.connectionsByDestinationNode?.[connectedNode.value.name]?.[
NodeConnectionType.AiMemory
];
if (!connectedMemoryInputs) return [];
const memoryConnection = (connectedMemoryInputs ?? []).find((i) => (i ?? []).length > 0)?.[0];
if (!memoryConnection) return [];
const nodeResultData = getWorkflowResultDataByNodeName(memoryConnection.node);
const memoryOutputData = (nodeResultData ?? [])
.map((data) => get(data, ['data', NodeConnectionType.AiMemory, 0, 0, 'json']) as MemoryOutput)
.find((data) => 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,
isLoading: computed(() => isLoading.value),
sendMessage,
extractResponseMessage,
getChatMessages,
};
}