mirror of
https://github.com/n8n-io/n8n.git
synced 2024-11-09 22:24:05 -08:00
feat: Add AI tool building capabilities (#7336)
Github issue / Community forum post (link here to close automatically): https://community.n8n.io/t/langchain-memory-chat/23733 --------- Signed-off-by: Oleg Ivaniv <me@olegivaniv.com> Co-authored-by: Oleg Ivaniv <me@olegivaniv.com> Co-authored-by: Val <68596159+valya@users.noreply.github.com> Co-authored-by: Alex Grozav <alex@grozav.com> Co-authored-by: कारतोफ्फेलस्क्रिप्ट™ <aditya@netroy.in> Co-authored-by: Deborah <deborah@starfallprojects.co.uk> Co-authored-by: Jesper Bylund <mail@jesperbylund.com> Co-authored-by: Jon <jonathan.bennetts@gmail.com> Co-authored-by: Michael Kret <88898367+michael-radency@users.noreply.github.com> Co-authored-by: Giulio Andreini <andreini@netseven.it> Co-authored-by: Mason Geloso <Mason.geloso@gmail.com> Co-authored-by: Mason Geloso <hone@Masons-Mac-mini.local> Co-authored-by: Mutasem Aldmour <mutasem@n8n.io>
This commit is contained in:
parent
dbfd617ace
commit
87def60979
48
cypress/composables/modals/chat-modal.ts
Normal file
48
cypress/composables/modals/chat-modal.ts
Normal file
|
@ -0,0 +1,48 @@
|
|||
/**
|
||||
* Getters
|
||||
*/
|
||||
|
||||
export function getManualChatModal() {
|
||||
return cy.getByTestId('lmChat-modal');
|
||||
}
|
||||
|
||||
export function getManualChatInput() {
|
||||
return cy.getByTestId('workflow-chat-input');
|
||||
}
|
||||
|
||||
export function getManualChatSendButton() {
|
||||
return getManualChatModal().getByTestId('workflow-chat-send-button');
|
||||
}
|
||||
|
||||
export function getManualChatMessages() {
|
||||
return getManualChatModal().get('.messages .message');
|
||||
}
|
||||
|
||||
export function getManualChatModalCloseButton() {
|
||||
return getManualChatModal().get('.el-dialog__close');
|
||||
}
|
||||
|
||||
export function getManualChatModalLogs() {
|
||||
return getManualChatModal().getByTestId('lm-chat-logs');
|
||||
}
|
||||
|
||||
export function getManualChatModalLogsTree() {
|
||||
return getManualChatModalLogs().getByTestId('lm-chat-logs-tree');
|
||||
}
|
||||
|
||||
export function getManualChatModalLogsEntries() {
|
||||
return getManualChatModalLogs().getByTestId('lm-chat-logs-entry');
|
||||
}
|
||||
|
||||
/**
|
||||
* Actions
|
||||
*/
|
||||
|
||||
export function sendManualChatMessage(message: string) {
|
||||
getManualChatInput().type(message);
|
||||
getManualChatSendButton().click();
|
||||
}
|
||||
|
||||
export function closeManualChatModal() {
|
||||
getManualChatModalCloseButton().click();
|
||||
}
|
54
cypress/composables/modals/credential-modal.ts
Normal file
54
cypress/composables/modals/credential-modal.ts
Normal file
|
@ -0,0 +1,54 @@
|
|||
/**
|
||||
* Getters
|
||||
*/
|
||||
|
||||
export function getCredentialConnectionParameterInputs() {
|
||||
return cy.getByTestId('credential-connection-parameter');
|
||||
}
|
||||
|
||||
export function getCredentialConnectionParameterInputByName(name: string) {
|
||||
return cy.getByTestId(`parameter-input-${name}`);
|
||||
}
|
||||
|
||||
export function getEditCredentialModal() {
|
||||
return cy.getByTestId('editCredential-modal', { timeout: 5000 });
|
||||
}
|
||||
|
||||
export function getCredentialSaveButton() {
|
||||
return cy.getByTestId('credential-save-button', { timeout: 5000 });
|
||||
}
|
||||
|
||||
export function getCredentialDeleteButton() {
|
||||
return cy.getByTestId('credential-delete-button');
|
||||
}
|
||||
|
||||
export function getCredentialModalCloseButton() {
|
||||
return getEditCredentialModal().find('.el-dialog__close').first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Actions
|
||||
*/
|
||||
|
||||
export function setCredentialConnectionParameterInputByName(name: string, value: string) {
|
||||
getCredentialConnectionParameterInputByName(name).type(value);
|
||||
}
|
||||
|
||||
export function saveCredential() {
|
||||
getCredentialSaveButton().click({ force: true });
|
||||
}
|
||||
|
||||
export function closeCredentialModal() {
|
||||
getCredentialModalCloseButton().click();
|
||||
}
|
||||
|
||||
export function setCredentialValues(values: Record<string, any>, save = true) {
|
||||
Object.entries(values).forEach(([key, value]) => {
|
||||
setCredentialConnectionParameterInputByName(key, value);
|
||||
});
|
||||
|
||||
if (save) {
|
||||
saveCredential();
|
||||
closeCredentialModal();
|
||||
}
|
||||
}
|
73
cypress/composables/ndv.ts
Normal file
73
cypress/composables/ndv.ts
Normal file
|
@ -0,0 +1,73 @@
|
|||
/**
|
||||
* Getters
|
||||
*/
|
||||
|
||||
export function getCredentialSelect(eq = 0) {
|
||||
return cy.getByTestId('node-credentials-select').eq(eq);
|
||||
}
|
||||
|
||||
export function getCreateNewCredentialOption() {
|
||||
return cy.getByTestId('node-credentials-select-item-new');
|
||||
}
|
||||
|
||||
export function getBackToCanvasButton() {
|
||||
return cy.getByTestId('back-to-canvas');
|
||||
}
|
||||
|
||||
export function getExecuteNodeButton() {
|
||||
return cy.getByTestId('node-execute-button');
|
||||
}
|
||||
|
||||
export function getParameterInputByName(name: string) {
|
||||
return cy.getByTestId(`parameter-input-${name}`);
|
||||
}
|
||||
|
||||
export function getInputPanel() {
|
||||
return cy.getByTestId('input-panel');
|
||||
}
|
||||
|
||||
export function getMainPanel() {
|
||||
return cy.getByTestId('node-parameters');
|
||||
}
|
||||
|
||||
export function getOutputPanel() {
|
||||
return cy.getByTestId('output-panel');
|
||||
}
|
||||
|
||||
export function getOutputPanelDataContainer() {
|
||||
return getOutputPanel().getByTestId('ndv-data-container');
|
||||
}
|
||||
|
||||
export function getOutputPanelTable() {
|
||||
return getOutputPanelDataContainer().get('table');
|
||||
}
|
||||
|
||||
/**
|
||||
* Actions
|
||||
*/
|
||||
|
||||
export function openCredentialSelect(eq = 0) {
|
||||
getCredentialSelect(eq).click();
|
||||
}
|
||||
|
||||
export function setCredentialByName(name: string) {
|
||||
openCredentialSelect();
|
||||
getCredentialSelect().contains(name).click();
|
||||
}
|
||||
|
||||
export function clickCreateNewCredential() {
|
||||
openCredentialSelect();
|
||||
getCreateNewCredentialOption().click();
|
||||
}
|
||||
|
||||
export function clickGetBackToCanvas() {
|
||||
getBackToCanvasButton().click();
|
||||
}
|
||||
|
||||
export function clickExecuteNode() {
|
||||
getExecuteNodeButton().click();
|
||||
}
|
||||
|
||||
export function setParameterInputByName(name: string, value: string) {
|
||||
getParameterInputByName(name).clear().type(value);
|
||||
}
|
142
cypress/composables/workflow.ts
Normal file
142
cypress/composables/workflow.ts
Normal file
|
@ -0,0 +1,142 @@
|
|||
import { ROUTES } from '../constants';
|
||||
import { getManualChatModal } from './modals/chat-modal';
|
||||
|
||||
/**
|
||||
* Types
|
||||
*/
|
||||
|
||||
export type EndpointType =
|
||||
| 'ai_chain'
|
||||
| 'ai_document'
|
||||
| 'ai_embedding'
|
||||
| 'ai_languageModel'
|
||||
| 'ai_memory'
|
||||
| 'ai_outputParser'
|
||||
| 'ai_tool'
|
||||
| 'ai_retriever'
|
||||
| 'ai_textSplitter'
|
||||
| 'ai_vectorRetriever'
|
||||
| 'ai_vectorStore';
|
||||
|
||||
/**
|
||||
* Getters
|
||||
*/
|
||||
|
||||
export function getAddInputEndpointByType(nodeName: string, endpointType: EndpointType) {
|
||||
return cy.get(
|
||||
`.add-input-endpoint[data-jtk-scope-${endpointType}][data-endpoint-name="${nodeName}"]`,
|
||||
);
|
||||
}
|
||||
|
||||
export function getNodeCreatorItems() {
|
||||
return cy.getByTestId('item-iterator-item');
|
||||
}
|
||||
|
||||
export function getExecuteWorkflowButton() {
|
||||
return cy.getByTestId('execute-workflow-button');
|
||||
}
|
||||
|
||||
export function getManualChatButton() {
|
||||
return cy.getByTestId('workflow-chat-button');
|
||||
}
|
||||
|
||||
export function getNodes() {
|
||||
return cy.getByTestId('canvas-node');
|
||||
}
|
||||
|
||||
export function getNodeByName(name: string) {
|
||||
return cy.getByTestId('canvas-node').filter(`[data-name="${name}"]`).eq(0);
|
||||
}
|
||||
|
||||
export function getConnectionBySourceAndTarget(source: string, target: string) {
|
||||
return cy
|
||||
.get('.jtk-connector')
|
||||
.filter(`[data-source-node="${source}"][data-target-node="${target}"]`)
|
||||
.eq(0);
|
||||
}
|
||||
|
||||
export function getNodeCreatorSearchBar() {
|
||||
return cy.getByTestId('node-creator-search-bar');
|
||||
}
|
||||
|
||||
export function getNodeCreatorPlusButton() {
|
||||
return cy.getByTestId('node-creator-plus-button');
|
||||
}
|
||||
|
||||
/**
|
||||
* Actions
|
||||
*/
|
||||
|
||||
export function addNodeToCanvas(
|
||||
nodeDisplayName: string,
|
||||
plusButtonClick = true,
|
||||
preventNdvClose?: boolean,
|
||||
action?: string,
|
||||
) {
|
||||
if (plusButtonClick) {
|
||||
getNodeCreatorPlusButton().click();
|
||||
}
|
||||
|
||||
getNodeCreatorSearchBar().type(nodeDisplayName);
|
||||
getNodeCreatorSearchBar().type('{enter}');
|
||||
cy.wait(500);
|
||||
cy.get('body').then((body) => {
|
||||
if (body.find('[data-test-id=node-creator]').length > 0) {
|
||||
if (action) {
|
||||
cy.contains(action).click();
|
||||
} else {
|
||||
// Select the first action
|
||||
cy.get('[data-keyboard-nav-type="action"]').eq(0).click();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!preventNdvClose) cy.get('body').type('{esc}');
|
||||
}
|
||||
|
||||
export function navigateToNewWorkflowPage(preventNodeViewUnload = true) {
|
||||
cy.visit(ROUTES.NEW_WORKFLOW_PAGE);
|
||||
cy.waitForLoad();
|
||||
cy.window().then((win) => {
|
||||
win.preventNodeViewBeforeUnload = preventNodeViewUnload;
|
||||
});
|
||||
}
|
||||
|
||||
export function addSupplementalNodeToParent(
|
||||
nodeName: string,
|
||||
endpointType: EndpointType,
|
||||
parentNodeName: string,
|
||||
) {
|
||||
getAddInputEndpointByType(parentNodeName, endpointType).click({ force: true });
|
||||
getNodeCreatorItems().contains(nodeName).click();
|
||||
getConnectionBySourceAndTarget(parentNodeName, nodeName).should('exist');
|
||||
}
|
||||
|
||||
export function addLanguageModelNodeToParent(nodeName: string, parentNodeName: string) {
|
||||
addSupplementalNodeToParent(nodeName, 'ai_languageModel', parentNodeName);
|
||||
}
|
||||
|
||||
export function addMemoryNodeToParent(nodeName: string, parentNodeName: string) {
|
||||
addSupplementalNodeToParent(nodeName, 'ai_memory', parentNodeName);
|
||||
}
|
||||
|
||||
export function addToolNodeToParent(nodeName: string, parentNodeName: string) {
|
||||
addSupplementalNodeToParent(nodeName, 'ai_tool', parentNodeName);
|
||||
}
|
||||
|
||||
export function addOutputParserNodeToParent(nodeName: string, parentNodeName: string) {
|
||||
addSupplementalNodeToParent(nodeName, 'ai_outputParser', parentNodeName);
|
||||
}
|
||||
|
||||
export function clickExecuteWorkflowButton() {
|
||||
getExecuteWorkflowButton().click();
|
||||
}
|
||||
|
||||
export function clickManualChatButton() {
|
||||
getManualChatButton().click();
|
||||
getManualChatModal().should('be.visible');
|
||||
}
|
||||
|
||||
export function openNode(nodeName: string) {
|
||||
getNodeByName(nodeName).dblclick();
|
||||
}
|
|
@ -29,6 +29,7 @@ export const INSTANCE_MEMBERS = [
|
|||
|
||||
export const MANUAL_TRIGGER_NODE_NAME = 'Manual Trigger';
|
||||
export const MANUAL_TRIGGER_NODE_DISPLAY_NAME = 'When clicking "Execute Workflow"';
|
||||
export const MANUAL_CHAT_TRIGGER_NODE_NAME = 'Manual Chat Trigger';
|
||||
export const SCHEDULE_TRIGGER_NODE_NAME = 'Schedule Trigger';
|
||||
export const CODE_NODE_NAME = 'Code';
|
||||
export const SET_NODE_NAME = 'Set';
|
||||
|
@ -41,6 +42,14 @@ export const TRELLO_NODE_NAME = 'Trello';
|
|||
export const NOTION_NODE_NAME = 'Notion';
|
||||
export const PIPEDRIVE_NODE_NAME = 'Pipedrive';
|
||||
export const HTTP_REQUEST_NODE_NAME = 'HTTP Request';
|
||||
export const AGENT_NODE_NAME = 'AI Agent';
|
||||
export const BASIC_LLM_CHAIN_NODE_NAME = 'Basic LLM Chain';
|
||||
export const AI_MEMORY_WINDOW_BUFFER_MEMORY_NODE_NAME = 'Window Buffer Memory';
|
||||
export const AI_TOOL_CALCULATOR_NODE_NAME = 'Calculator';
|
||||
export const AI_TOOL_CODE_NODE_NAME = 'Custom Code Tool';
|
||||
export const AI_TOOL_WIKIPEDIA_NODE_NAME = 'Wikipedia';
|
||||
export const AI_LANGUAGE_MODEL_OPENAI_CHAT_MODEL_NODE_NAME = 'OpenAI Chat Model';
|
||||
export const AI_OUTPUT_PARSER_AUTO_FIXING_NODE_NAME = 'Auto-fixing Output Parser';
|
||||
|
||||
export const META_KEY = Cypress.platform === 'darwin' ? '{meta}' : '{ctrl}';
|
||||
|
||||
|
@ -48,3 +57,7 @@ export const NEW_GOOGLE_ACCOUNT_NAME = 'Gmail account';
|
|||
export const NEW_TRELLO_ACCOUNT_NAME = 'Trello account';
|
||||
export const NEW_NOTION_ACCOUNT_NAME = 'Notion account';
|
||||
export const NEW_QUERY_AUTH_ACCOUNT_NAME = 'Query Auth account';
|
||||
|
||||
export const ROUTES = {
|
||||
NEW_WORKFLOW_PAGE: '/workflow/new',
|
||||
};
|
||||
|
|
278
cypress/e2e/30-langchain.cy.ts
Normal file
278
cypress/e2e/30-langchain.cy.ts
Normal file
|
@ -0,0 +1,278 @@
|
|||
import {
|
||||
AGENT_NODE_NAME,
|
||||
MANUAL_CHAT_TRIGGER_NODE_NAME,
|
||||
AI_LANGUAGE_MODEL_OPENAI_CHAT_MODEL_NODE_NAME,
|
||||
MANUAL_TRIGGER_NODE_NAME,
|
||||
AI_MEMORY_WINDOW_BUFFER_MEMORY_NODE_NAME,
|
||||
AI_TOOL_CALCULATOR_NODE_NAME,
|
||||
AI_OUTPUT_PARSER_AUTO_FIXING_NODE_NAME,
|
||||
AI_TOOL_CODE_NODE_NAME,
|
||||
AI_TOOL_WIKIPEDIA_NODE_NAME,
|
||||
BASIC_LLM_CHAIN_NODE_NAME,
|
||||
} from './../constants';
|
||||
import { createMockNodeExecutionData, runMockWorkflowExcution } from '../utils';
|
||||
import {
|
||||
addLanguageModelNodeToParent,
|
||||
addMemoryNodeToParent,
|
||||
addNodeToCanvas,
|
||||
addOutputParserNodeToParent,
|
||||
addToolNodeToParent,
|
||||
clickManualChatButton,
|
||||
navigateToNewWorkflowPage,
|
||||
openNode,
|
||||
} from '../composables/workflow';
|
||||
import {
|
||||
clickCreateNewCredential,
|
||||
clickExecuteNode,
|
||||
clickGetBackToCanvas,
|
||||
getOutputPanelTable,
|
||||
setParameterInputByName,
|
||||
} from '../composables/ndv';
|
||||
import { setCredentialValues } from '../composables/modals/credential-modal';
|
||||
import {
|
||||
closeManualChatModal,
|
||||
getManualChatMessages,
|
||||
getManualChatModalLogs,
|
||||
getManualChatModalLogsEntries,
|
||||
getManualChatModalLogsTree,
|
||||
sendManualChatMessage,
|
||||
} from '../composables/modals/chat-modal';
|
||||
|
||||
describe('Langchain Integration', () => {
|
||||
beforeEach(() => {
|
||||
navigateToNewWorkflowPage();
|
||||
});
|
||||
|
||||
it('should add nodes to all Agent node input types', () => {
|
||||
addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME, true);
|
||||
addNodeToCanvas(AGENT_NODE_NAME, true);
|
||||
|
||||
addLanguageModelNodeToParent(AI_LANGUAGE_MODEL_OPENAI_CHAT_MODEL_NODE_NAME, AGENT_NODE_NAME);
|
||||
clickGetBackToCanvas();
|
||||
|
||||
addMemoryNodeToParent(AI_MEMORY_WINDOW_BUFFER_MEMORY_NODE_NAME, AGENT_NODE_NAME);
|
||||
clickGetBackToCanvas();
|
||||
|
||||
addToolNodeToParent(AI_TOOL_CALCULATOR_NODE_NAME, AGENT_NODE_NAME);
|
||||
clickGetBackToCanvas();
|
||||
|
||||
addOutputParserNodeToParent(AI_OUTPUT_PARSER_AUTO_FIXING_NODE_NAME, AGENT_NODE_NAME);
|
||||
clickGetBackToCanvas();
|
||||
});
|
||||
|
||||
it('should add multiple tool nodes to Agent node tool input type', () => {
|
||||
addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME, true);
|
||||
addNodeToCanvas(AGENT_NODE_NAME, true);
|
||||
|
||||
[
|
||||
AI_TOOL_CALCULATOR_NODE_NAME,
|
||||
AI_TOOL_CODE_NODE_NAME,
|
||||
AI_TOOL_CODE_NODE_NAME,
|
||||
AI_TOOL_CODE_NODE_NAME,
|
||||
AI_TOOL_WIKIPEDIA_NODE_NAME,
|
||||
].forEach((tool) => {
|
||||
addToolNodeToParent(tool, AGENT_NODE_NAME);
|
||||
clickGetBackToCanvas();
|
||||
});
|
||||
});
|
||||
|
||||
it('should be able to open and execute Basic LLM Chain node', () => {
|
||||
addNodeToCanvas(MANUAL_CHAT_TRIGGER_NODE_NAME, true);
|
||||
addNodeToCanvas(BASIC_LLM_CHAIN_NODE_NAME, true);
|
||||
|
||||
addLanguageModelNodeToParent(
|
||||
AI_LANGUAGE_MODEL_OPENAI_CHAT_MODEL_NODE_NAME,
|
||||
BASIC_LLM_CHAIN_NODE_NAME,
|
||||
);
|
||||
|
||||
clickCreateNewCredential();
|
||||
setCredentialValues({
|
||||
apiKey: 'sk_test_123',
|
||||
});
|
||||
clickGetBackToCanvas();
|
||||
|
||||
openNode(BASIC_LLM_CHAIN_NODE_NAME);
|
||||
|
||||
const inputMessage = 'Hello!';
|
||||
const outputMessage = 'Hi there! How can I assist you today?';
|
||||
|
||||
setParameterInputByName('prompt', inputMessage);
|
||||
|
||||
runMockWorkflowExcution({
|
||||
trigger: () => clickExecuteNode(),
|
||||
runData: [
|
||||
createMockNodeExecutionData(BASIC_LLM_CHAIN_NODE_NAME, {
|
||||
jsonData: {
|
||||
main: { output: outputMessage },
|
||||
},
|
||||
metadata: {
|
||||
subRun: [{ node: AI_LANGUAGE_MODEL_OPENAI_CHAT_MODEL_NODE_NAME, runIndex: 0 }],
|
||||
},
|
||||
}),
|
||||
],
|
||||
lastNodeExecuted: BASIC_LLM_CHAIN_NODE_NAME,
|
||||
});
|
||||
|
||||
getOutputPanelTable().should('contain', 'output');
|
||||
getOutputPanelTable().should('contain', outputMessage);
|
||||
});
|
||||
|
||||
it('should be able to open and execute Agent node', () => {
|
||||
addNodeToCanvas(MANUAL_CHAT_TRIGGER_NODE_NAME, true);
|
||||
addNodeToCanvas(AGENT_NODE_NAME, true);
|
||||
|
||||
addLanguageModelNodeToParent(AI_LANGUAGE_MODEL_OPENAI_CHAT_MODEL_NODE_NAME, AGENT_NODE_NAME);
|
||||
|
||||
clickCreateNewCredential();
|
||||
setCredentialValues({
|
||||
apiKey: 'sk_test_123',
|
||||
});
|
||||
clickGetBackToCanvas();
|
||||
|
||||
openNode(AGENT_NODE_NAME);
|
||||
|
||||
const inputMessage = 'Hello!';
|
||||
const outputMessage = 'Hi there! How can I assist you today?';
|
||||
|
||||
setParameterInputByName('text', inputMessage);
|
||||
|
||||
runMockWorkflowExcution({
|
||||
trigger: () => clickExecuteNode(),
|
||||
runData: [
|
||||
createMockNodeExecutionData(AGENT_NODE_NAME, {
|
||||
jsonData: {
|
||||
main: { output: outputMessage },
|
||||
},
|
||||
metadata: {
|
||||
subRun: [{ node: AI_LANGUAGE_MODEL_OPENAI_CHAT_MODEL_NODE_NAME, runIndex: 0 }],
|
||||
},
|
||||
}),
|
||||
],
|
||||
lastNodeExecuted: AGENT_NODE_NAME,
|
||||
});
|
||||
|
||||
getOutputPanelTable().should('contain', 'output');
|
||||
getOutputPanelTable().should('contain', outputMessage);
|
||||
});
|
||||
|
||||
it('should add and use Manual Chat Trigger node together with Agent node', () => {
|
||||
addNodeToCanvas(MANUAL_CHAT_TRIGGER_NODE_NAME, true);
|
||||
addNodeToCanvas(AGENT_NODE_NAME, true);
|
||||
|
||||
addLanguageModelNodeToParent(AI_LANGUAGE_MODEL_OPENAI_CHAT_MODEL_NODE_NAME, AGENT_NODE_NAME);
|
||||
|
||||
clickCreateNewCredential();
|
||||
setCredentialValues({
|
||||
apiKey: 'sk_test_123',
|
||||
});
|
||||
clickGetBackToCanvas();
|
||||
|
||||
clickManualChatButton();
|
||||
|
||||
getManualChatModalLogs().should('not.exist');
|
||||
|
||||
const inputMessage = 'Hello!';
|
||||
const outputMessage = 'Hi there! How can I assist you today?';
|
||||
|
||||
runMockWorkflowExcution({
|
||||
trigger: () => {
|
||||
sendManualChatMessage(inputMessage);
|
||||
},
|
||||
runData: [
|
||||
createMockNodeExecutionData(MANUAL_CHAT_TRIGGER_NODE_NAME, {
|
||||
jsonData: {
|
||||
main: { input: inputMessage },
|
||||
},
|
||||
}),
|
||||
createMockNodeExecutionData(AI_LANGUAGE_MODEL_OPENAI_CHAT_MODEL_NODE_NAME, {
|
||||
jsonData: {
|
||||
ai_languageModel: {
|
||||
response: {
|
||||
generations: [
|
||||
{
|
||||
text: `{
|
||||
"action": "Final Answer",
|
||||
"action_input": "${outputMessage}"
|
||||
}`,
|
||||
message: {
|
||||
lc: 1,
|
||||
type: 'constructor',
|
||||
id: ['langchain', 'schema', 'AIMessage'],
|
||||
kwargs: {
|
||||
content: `{
|
||||
"action": "Final Answer",
|
||||
"action_input": "${outputMessage}"
|
||||
}`,
|
||||
additional_kwargs: {},
|
||||
},
|
||||
},
|
||||
generationInfo: { finish_reason: 'stop' },
|
||||
},
|
||||
],
|
||||
llmOutput: {
|
||||
tokenUsage: {
|
||||
completionTokens: 26,
|
||||
promptTokens: 519,
|
||||
totalTokens: 545,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
inputOverride: {
|
||||
ai_languageModel: [
|
||||
[
|
||||
{
|
||||
json: {
|
||||
messages: [
|
||||
{
|
||||
lc: 1,
|
||||
type: 'constructor',
|
||||
id: ['langchain', 'schema', 'SystemMessage'],
|
||||
kwargs: {
|
||||
content:
|
||||
'Assistant is a large language model trained by OpenAI.\n\nAssistant is designed to be able to assist with a wide range of tasks, from answering simple questions to providing in-depth explanations and discussions on a wide range of topics. As a language model, Assistant is able to generate human-like text based on the input it receives, allowing it to engage in natural-sounding conversations and provide responses that are coherent and relevant to the topic at hand.\n\nAssistant is constantly learning and improving, and its capabilities are constantly evolving. It is able to process and understand large amounts of text, and can use this knowledge to provide accurate and informative responses to a wide range of questions. Additionally, Assistant is able to generate its own text based on the input it receives, allowing it to engage in discussions and provide explanations and descriptions on a wide range of topics.\n\nOverall, Assistant is a powerful system that can help with a wide range of tasks and provide valuable insights and information on a wide range of topics. Whether you need help with a specific question or just want to have a conversation about a particular topic, Assistant is here to assist. However, above all else, all responses must adhere to the format of RESPONSE FORMAT INSTRUCTIONS.',
|
||||
additional_kwargs: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
lc: 1,
|
||||
type: 'constructor',
|
||||
id: ['langchain', 'schema', 'HumanMessage'],
|
||||
kwargs: {
|
||||
content:
|
||||
'TOOLS\n------\nAssistant can ask the user to use tools to look up information that may be helpful in answering the users original question. The tools the human can use are:\n\n\n\nRESPONSE FORMAT INSTRUCTIONS\n----------------------------\n\nOutput a JSON markdown code snippet containing a valid JSON object in one of two formats:\n\n**Option 1:**\nUse this if you want the human to use a tool.\nMarkdown code snippet formatted in the following schema:\n\n```json\n{\n "action": string, // The action to take. Must be one of []\n "action_input": string // The input to the action. May be a stringified object.\n}\n```\n\n**Option #2:**\nUse this if you want to respond directly and conversationally to the human. Markdown code snippet formatted in the following schema:\n\n```json\n{\n "action": "Final Answer",\n "action_input": string // You should put what you want to return to use here and make sure to use valid json newline characters.\n}\n```\n\nFor both options, remember to always include the surrounding markdown code snippet delimiters (begin with "```json" and end with "```")!\n\n\nUSER\'S INPUT\n--------------------\nHere is the user\'s input (remember to respond with a markdown code snippet of a json blob with a single action, and NOTHING else):\n\nHello!',
|
||||
additional_kwargs: {},
|
||||
},
|
||||
},
|
||||
],
|
||||
options: { stop: ['Observation:'], promptIndex: 0 },
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
}),
|
||||
createMockNodeExecutionData(AGENT_NODE_NAME, {
|
||||
jsonData: {
|
||||
main: { output: 'Hi there! How can I assist you today?' },
|
||||
},
|
||||
metadata: {
|
||||
subRun: [{ node: AI_LANGUAGE_MODEL_OPENAI_CHAT_MODEL_NODE_NAME, runIndex: 0 }],
|
||||
},
|
||||
}),
|
||||
],
|
||||
lastNodeExecuted: AGENT_NODE_NAME,
|
||||
});
|
||||
|
||||
const messages = getManualChatMessages();
|
||||
messages.should('have.length', 2);
|
||||
messages.should('contain', inputMessage);
|
||||
messages.should('contain', outputMessage);
|
||||
|
||||
getManualChatModalLogsTree().should('be.visible');
|
||||
getManualChatModalLogsEntries().should('have.length', 1);
|
||||
|
||||
closeManualChatModal();
|
||||
});
|
||||
});
|
|
@ -34,7 +34,7 @@ describe('Node Creator', () => {
|
|||
nodeCreatorFeature.actions.openNodeCreator();
|
||||
|
||||
nodeCreatorFeature.getters.searchBar().find('input').type('manual');
|
||||
nodeCreatorFeature.getters.creatorItem().should('have.length', 1);
|
||||
nodeCreatorFeature.getters.creatorItem().should('have.length', 3);
|
||||
nodeCreatorFeature.getters.searchBar().find('input').clear().type('manual123');
|
||||
nodeCreatorFeature.getters.creatorItem().should('have.length', 0);
|
||||
nodeCreatorFeature.getters
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { v4 as uuid } from 'uuid';
|
||||
import { NDV, WorkflowPage } from '../pages';
|
||||
import { getVisibleSelect } from '../utils';
|
||||
import { MANUAL_TRIGGER_NODE_DISPLAY_NAME } from '../constants';
|
||||
import { NDV, WorkflowPage } from '../pages';
|
||||
|
||||
const workflowPage = new WorkflowPage();
|
||||
const ndv = new NDV();
|
||||
|
@ -368,6 +369,109 @@ describe('NDV', () => {
|
|||
cy.get('@fetchParameterOptions').should('have.been.calledOnce');
|
||||
});
|
||||
|
||||
describe('floating nodes', () => {
|
||||
function getFloatingNodeByPosition(position: 'inputMain' | 'outputMain' | 'outputSub'| 'inputSub') {
|
||||
return cy.get(`[data-node-placement=${position}]`);
|
||||
}
|
||||
beforeEach(() => {
|
||||
cy.createFixtureWorkflow('Floating_Nodes.json', `Floating Nodes`);
|
||||
workflowPage.getters.canvasNodes().first().dblclick()
|
||||
getFloatingNodeByPosition("inputMain").should('not.exist');
|
||||
getFloatingNodeByPosition("outputMain").should('exist');
|
||||
});
|
||||
|
||||
it('should traverse floating nodes with mouse', () => {
|
||||
// Traverse 4 connected node forwards
|
||||
Array.from(Array(4).keys()).forEach(i => {
|
||||
getFloatingNodeByPosition("outputMain").click({ force: true});
|
||||
ndv.getters.nodeNameContainer().should('contain', `Node ${i + 1}`);
|
||||
getFloatingNodeByPosition("inputMain").should('exist');
|
||||
getFloatingNodeByPosition("outputMain").should('exist');
|
||||
ndv.actions.close();
|
||||
workflowPage.getters.selectedNodes().should('have.length', 1);
|
||||
workflowPage.getters.selectedNodes().first().should('contain', `Node ${i + 1}`);
|
||||
workflowPage.getters.selectedNodes().first().dblclick();
|
||||
})
|
||||
|
||||
getFloatingNodeByPosition("outputMain").click({ force: true});
|
||||
ndv.getters.nodeNameContainer().should('contain', 'Chain');
|
||||
getFloatingNodeByPosition("inputSub").should('exist');
|
||||
getFloatingNodeByPosition("inputSub").click({ force: true});
|
||||
ndv.getters.nodeNameContainer().should('contain', 'Model');
|
||||
getFloatingNodeByPosition("inputSub").should('not.exist');
|
||||
getFloatingNodeByPosition("inputMain").should('not.exist');
|
||||
getFloatingNodeByPosition("outputMain").should('not.exist');
|
||||
getFloatingNodeByPosition("outputSub").should('exist');
|
||||
ndv.actions.close();
|
||||
workflowPage.getters.selectedNodes().should('have.length', 1);
|
||||
workflowPage.getters.selectedNodes().first().should('contain', 'Model');
|
||||
workflowPage.getters.selectedNodes().first().dblclick();
|
||||
getFloatingNodeByPosition("outputSub").click({ force: true});
|
||||
ndv.getters.nodeNameContainer().should('contain', 'Chain');
|
||||
|
||||
// Traverse 4 connected node backwards
|
||||
Array.from(Array(4).keys()).forEach(i => {
|
||||
getFloatingNodeByPosition("inputMain").click({ force: true});
|
||||
ndv.getters.nodeNameContainer().should('contain', `Node ${4 - (i)}`);
|
||||
getFloatingNodeByPosition("outputMain").should('exist');
|
||||
getFloatingNodeByPosition("inputMain").should('exist');
|
||||
})
|
||||
getFloatingNodeByPosition("inputMain").click({ force: true});
|
||||
workflowPage.getters.selectedNodes().first().should('contain', MANUAL_TRIGGER_NODE_DISPLAY_NAME);
|
||||
getFloatingNodeByPosition("inputMain").should('not.exist');
|
||||
getFloatingNodeByPosition("inputSub").should('not.exist');
|
||||
getFloatingNodeByPosition("outputSub").should('not.exist');
|
||||
ndv.actions.close();
|
||||
workflowPage.getters.selectedNodes().should('have.length', 1);
|
||||
workflowPage.getters.selectedNodes().first().should('contain', MANUAL_TRIGGER_NODE_DISPLAY_NAME);
|
||||
});
|
||||
|
||||
it('should traverse floating nodes with mouse', () => {
|
||||
// Traverse 4 connected node forwards
|
||||
Array.from(Array(4).keys()).forEach(i => {
|
||||
cy.realPress(['ShiftLeft', 'Meta', 'AltLeft', 'ArrowRight'])
|
||||
ndv.getters.nodeNameContainer().should('contain', `Node ${i + 1}`);
|
||||
getFloatingNodeByPosition("inputMain").should('exist');
|
||||
getFloatingNodeByPosition("outputMain").should('exist');
|
||||
ndv.actions.close();
|
||||
workflowPage.getters.selectedNodes().should('have.length', 1);
|
||||
workflowPage.getters.selectedNodes().first().should('contain', `Node ${i + 1}`);
|
||||
workflowPage.getters.selectedNodes().first().dblclick();
|
||||
})
|
||||
|
||||
cy.realPress(['ShiftLeft', 'Meta', 'AltLeft', 'ArrowRight'])
|
||||
ndv.getters.nodeNameContainer().should('contain', 'Chain');
|
||||
getFloatingNodeByPosition("inputSub").should('exist');
|
||||
cy.realPress(['ShiftLeft', 'Meta', 'AltLeft', 'ArrowDown'])
|
||||
ndv.getters.nodeNameContainer().should('contain', 'Model');
|
||||
getFloatingNodeByPosition("inputSub").should('not.exist');
|
||||
getFloatingNodeByPosition("inputMain").should('not.exist');
|
||||
getFloatingNodeByPosition("outputMain").should('not.exist');
|
||||
getFloatingNodeByPosition("outputSub").should('exist');
|
||||
ndv.actions.close();
|
||||
workflowPage.getters.selectedNodes().should('have.length', 1);
|
||||
workflowPage.getters.selectedNodes().first().should('contain', 'Model');
|
||||
workflowPage.getters.selectedNodes().first().dblclick();
|
||||
cy.realPress(['ShiftLeft', 'Meta', 'AltLeft', 'ArrowUp'])
|
||||
ndv.getters.nodeNameContainer().should('contain', 'Chain');
|
||||
|
||||
// Traverse 4 connected node backwards
|
||||
Array.from(Array(4).keys()).forEach(i => {
|
||||
cy.realPress(['ShiftLeft', 'Meta', 'AltLeft', 'ArrowLeft'])
|
||||
ndv.getters.nodeNameContainer().should('contain', `Node ${4 - (i)}`);
|
||||
getFloatingNodeByPosition("outputMain").should('exist');
|
||||
getFloatingNodeByPosition("inputMain").should('exist');
|
||||
})
|
||||
cy.realPress(['ShiftLeft', 'Meta', 'AltLeft', 'ArrowLeft'])
|
||||
workflowPage.getters.selectedNodes().first().should('contain', MANUAL_TRIGGER_NODE_DISPLAY_NAME);
|
||||
getFloatingNodeByPosition("inputMain").should('not.exist');
|
||||
getFloatingNodeByPosition("inputSub").should('not.exist');
|
||||
getFloatingNodeByPosition("outputSub").should('not.exist');
|
||||
ndv.actions.close();
|
||||
workflowPage.getters.selectedNodes().should('have.length', 1);
|
||||
workflowPage.getters.selectedNodes().first().should('contain', MANUAL_TRIGGER_NODE_DISPLAY_NAME);
|
||||
});
|
||||
})
|
||||
it('should show node name and version in settings', () => {
|
||||
cy.createFixtureWorkflow('Test_workflow_ndv_version.json', `NDV test version ${uuid()}`);
|
||||
|
||||
|
|
176
cypress/fixtures/Floating_Nodes.json
Normal file
176
cypress/fixtures/Floating_Nodes.json
Normal file
|
@ -0,0 +1,176 @@
|
|||
{
|
||||
"name": "Floating Nodes",
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": {},
|
||||
"id": "d0eda550-2526-42a1-aa19-dee411c8acf9",
|
||||
"name": "When clicking \"Execute Workflow\"",
|
||||
"type": "n8n-nodes-base.manualTrigger",
|
||||
"typeVersion": 1,
|
||||
"position": [
|
||||
700,
|
||||
560
|
||||
]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"options": {}
|
||||
},
|
||||
"id": "30412165-1229-4b21-9890-05bfbd9952ab",
|
||||
"name": "Node 1",
|
||||
"type": "n8n-nodes-base.set",
|
||||
"typeVersion": 3.2,
|
||||
"position": [
|
||||
920,
|
||||
560
|
||||
]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"options": {}
|
||||
},
|
||||
"id": "201cc8fc-3124-47a3-bc08-b3917c1ddcd9",
|
||||
"name": "Node 2",
|
||||
"type": "n8n-nodes-base.set",
|
||||
"typeVersion": 3.2,
|
||||
"position": [
|
||||
1100,
|
||||
560
|
||||
]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"options": {}
|
||||
},
|
||||
"id": "a29802bb-a284-495d-9917-6c6e42fef01e",
|
||||
"name": "Node 3",
|
||||
"type": "n8n-nodes-base.set",
|
||||
"typeVersion": 3.2,
|
||||
"position": [
|
||||
1280,
|
||||
560
|
||||
]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"options": {}
|
||||
},
|
||||
"id": "a95a72b3-8b39-44e2-a05b-d8d677741c80",
|
||||
"name": "Node 4",
|
||||
"type": "n8n-nodes-base.set",
|
||||
"typeVersion": 3.2,
|
||||
"position": [
|
||||
1440,
|
||||
560
|
||||
]
|
||||
},
|
||||
{
|
||||
"parameters": {},
|
||||
"id": "4674f10d-6144-4a17-bbbb-350c3974438e",
|
||||
"name": "Chain",
|
||||
"type": "@n8n/n8n-nodes-langchain.chainLlm",
|
||||
"typeVersion": 1,
|
||||
"position": [
|
||||
1580,
|
||||
560
|
||||
]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"options": {}
|
||||
},
|
||||
"id": "58e12ea5-bd3e-4abf-abec-fcfb5c0a7955",
|
||||
"name": "Model",
|
||||
"type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
|
||||
"typeVersion": 1,
|
||||
"position": [
|
||||
1600,
|
||||
740
|
||||
]
|
||||
}
|
||||
],
|
||||
"pinData": {},
|
||||
"connections": {
|
||||
"When clicking \"Execute Workflow\"": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Node 1",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Node 1": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Node 2",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Node 3": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Node 4",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Node 2": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Node 3",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Chain": {
|
||||
"main": [
|
||||
[]
|
||||
]
|
||||
},
|
||||
"Model": {
|
||||
"ai_languageModel": [
|
||||
[
|
||||
{
|
||||
"node": "Chain",
|
||||
"type": "ai_languageModel",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Node 4": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Chain",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"active": false,
|
||||
"settings": {
|
||||
"executionOrder": "v1"
|
||||
},
|
||||
"versionId": "2730d156-a98a-4ac8-b481-5c16361fdba2",
|
||||
"id": "6bzXMGxHuxeEaqsA",
|
||||
"meta": {
|
||||
"instanceId": "1838be0fa0389fbaf5e2e4aaedab4ddc79abc4175b433401abb22a281001b853"
|
||||
},
|
||||
"tags": []
|
||||
}
|
|
@ -2,7 +2,6 @@ import { META_KEY } from '../constants';
|
|||
import { BasePage } from './base';
|
||||
import { getVisibleSelect } from '../utils';
|
||||
import { NodeCreator } from './features/node-creator';
|
||||
import Chainable = Cypress.Chainable;
|
||||
|
||||
const nodeCreator = new NodeCreator();
|
||||
export class WorkflowPage extends BasePage {
|
||||
|
|
135
cypress/utils/executions.ts
Normal file
135
cypress/utils/executions.ts
Normal file
|
@ -0,0 +1,135 @@
|
|||
import { ITaskData } from '../../packages/workflow/src';
|
||||
import { IPinData } from '../../packages/workflow';
|
||||
import { clickExecuteWorkflowButton } from '../composables/workflow';
|
||||
|
||||
export function createMockNodeExecutionData(
|
||||
name: string,
|
||||
{
|
||||
data,
|
||||
inputOverride,
|
||||
executionStatus = 'success',
|
||||
jsonData,
|
||||
...rest
|
||||
}: Partial<ITaskData> & { jsonData?: Record<string, object> },
|
||||
): Record<string, ITaskData> {
|
||||
return {
|
||||
[name]: {
|
||||
startTime: new Date().getTime(),
|
||||
executionTime: 0,
|
||||
executionStatus,
|
||||
data: jsonData
|
||||
? Object.keys(jsonData).reduce((acc, key) => {
|
||||
acc[key] = [
|
||||
[
|
||||
{
|
||||
json: jsonData[key],
|
||||
pairedItem: { item: 0 },
|
||||
},
|
||||
],
|
||||
];
|
||||
|
||||
return acc;
|
||||
}, {})
|
||||
: data,
|
||||
source: [null],
|
||||
...rest,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createMockWorkflowExecutionData({
|
||||
executionId,
|
||||
runData,
|
||||
pinData = {},
|
||||
lastNodeExecuted,
|
||||
}: {
|
||||
executionId: string;
|
||||
runData: Record<string, ITaskData | ITaskData[]>;
|
||||
pinData?: IPinData;
|
||||
lastNodeExecuted: string;
|
||||
}) {
|
||||
return {
|
||||
executionId,
|
||||
data: {
|
||||
data: {
|
||||
startData: {},
|
||||
resultData: {
|
||||
runData,
|
||||
pinData,
|
||||
lastNodeExecuted,
|
||||
},
|
||||
executionData: {
|
||||
contextData: {},
|
||||
nodeExecutionStack: [],
|
||||
metadata: {},
|
||||
waitingExecution: {},
|
||||
waitingExecutionSource: {},
|
||||
},
|
||||
},
|
||||
mode: 'manual',
|
||||
startedAt: new Date().toISOString(),
|
||||
stoppedAt: new Date().toISOString(),
|
||||
status: 'success',
|
||||
finished: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function runMockWorkflowExcution({
|
||||
trigger,
|
||||
lastNodeExecuted,
|
||||
runData,
|
||||
workflowExecutionData,
|
||||
}: {
|
||||
trigger?: () => void;
|
||||
lastNodeExecuted: string;
|
||||
runData: Array<ReturnType<typeof createMockNodeExecutionData>>;
|
||||
workflowExecutionData?: ReturnType<typeof createMockWorkflowExecutionData>;
|
||||
}) {
|
||||
const executionId = Math.random().toString(36).substring(4);
|
||||
|
||||
cy.intercept('POST', '/rest/workflows/run', {
|
||||
statusCode: 201,
|
||||
body: {
|
||||
data: {
|
||||
executionId,
|
||||
},
|
||||
},
|
||||
}).as('runWorkflow');
|
||||
|
||||
if (trigger) {
|
||||
trigger();
|
||||
} else {
|
||||
clickExecuteWorkflowButton();
|
||||
}
|
||||
|
||||
cy.wait('@runWorkflow');
|
||||
|
||||
const resolvedRunData = {};
|
||||
runData.forEach((nodeExecution) => {
|
||||
const nodeName = Object.keys(nodeExecution)[0];
|
||||
const nodeRunData = nodeExecution[nodeName];
|
||||
|
||||
cy.push('nodeExecuteBefore', {
|
||||
executionId,
|
||||
nodeName,
|
||||
});
|
||||
cy.push('nodeExecuteAfter', {
|
||||
executionId,
|
||||
nodeName,
|
||||
data: nodeRunData,
|
||||
});
|
||||
|
||||
resolvedRunData[nodeName] = nodeExecution[nodeName];
|
||||
});
|
||||
|
||||
cy.push(
|
||||
'executionFinished',
|
||||
createMockWorkflowExecutionData({
|
||||
executionId,
|
||||
lastNodeExecuted,
|
||||
runData: resolvedRunData,
|
||||
...workflowExecutionData,
|
||||
}),
|
||||
);
|
||||
}
|
|
@ -1 +1,3 @@
|
|||
export * from './executions';
|
||||
export * from './modal';
|
||||
export * from './popper';
|
||||
|
|
|
@ -11,10 +11,11 @@
|
|||
"scripts": {
|
||||
"preinstall": "node scripts/block-npm-install.js",
|
||||
"build": "turbo run build",
|
||||
"build:backend": "pnpm --filter=!n8n-design-system --filter=!n8n-editor-ui build",
|
||||
"build:frontend": "pnpm --filter=n8n-design-system --filter=n8n-editor-ui build",
|
||||
"build:backend": "pnpm --filter=!@n8n/chat --filter=!n8n-design-system --filter=!n8n-editor-ui build",
|
||||
"build:frontend": "pnpm --filter=@n8n/chat --filter=n8n-design-system --filter=n8n-editor-ui build",
|
||||
"typecheck": "turbo run typecheck",
|
||||
"dev": "turbo run dev --parallel",
|
||||
"dev:ai": "turbo run dev --parallel --filter=@n8n/nodes-langchain --filter=n8n --filter=n8n-core",
|
||||
"clean": "turbo run clean --parallel",
|
||||
"format": "turbo run format && node scripts/format.mjs",
|
||||
"lint": "turbo run lint",
|
||||
|
@ -25,9 +26,9 @@
|
|||
"start:tunnel": "./packages/cli/bin/n8n start --tunnel",
|
||||
"start:windows": "cd packages/cli/bin && n8n",
|
||||
"test": "turbo run test",
|
||||
"test:backend": "pnpm --filter=!n8n-design-system --filter=!n8n-editor-ui --filter=!n8n-nodes-base test",
|
||||
"test:backend": "pnpm --filter=!@n8n/chat --filter=!n8n-design-system --filter=!n8n-editor-ui --filter=!n8n-nodes-base test",
|
||||
"test:nodes": "pnpm --filter=n8n-nodes-base test",
|
||||
"test:frontend": "pnpm --filter=n8n-design-system --filter=n8n-editor-ui test",
|
||||
"test:frontend": "pnpm --filter=@n8n/chat --filter=n8n-design-system --filter=n8n-editor-ui test",
|
||||
"watch": "turbo run watch",
|
||||
"webhook": "./packages/cli/bin/n8n webhook",
|
||||
"worker": "./packages/cli/bin/n8n worker",
|
||||
|
|
2
packages/@n8n/chat/.eslintignore
Normal file
2
packages/@n8n/chat/.eslintignore
Normal file
|
@ -0,0 +1,2 @@
|
|||
.eslintrc.cjs
|
||||
vitest.config.ts
|
53
packages/@n8n/chat/.eslintrc.cjs
Normal file
53
packages/@n8n/chat/.eslintrc.cjs
Normal file
|
@ -0,0 +1,53 @@
|
|||
const sharedOptions = require('@n8n_io/eslint-config/shared');
|
||||
|
||||
/**
|
||||
* @type {import('@types/eslint').ESLint.ConfigData}
|
||||
*/
|
||||
module.exports = {
|
||||
extends: ['@n8n_io/eslint-config/frontend'],
|
||||
|
||||
...sharedOptions(__dirname, 'frontend'),
|
||||
|
||||
rules: {
|
||||
// TODO: Remove these
|
||||
'id-denylist': 'warn',
|
||||
'import/extensions': 'warn',
|
||||
'import/no-default-export': 'warn',
|
||||
'import/no-extraneous-dependencies': 'warn',
|
||||
'import/order': 'off',
|
||||
'import/no-cycle': 'warn',
|
||||
'import/no-duplicates': 'warn',
|
||||
'@typescript-eslint/ban-types': 'warn',
|
||||
'@typescript-eslint/dot-notation': 'warn',
|
||||
'@typescript-eslint/lines-between-class-members': 'warn',
|
||||
'@typescript-eslint/member-delimiter-style': 'warn',
|
||||
'@typescript-eslint/naming-convention': 'warn',
|
||||
'@typescript-eslint/no-empty-interface': 'warn',
|
||||
'@typescript-eslint/no-for-in-array': 'warn',
|
||||
'@typescript-eslint/no-loop-func': 'warn',
|
||||
'@typescript-eslint/no-non-null-assertion': 'warn',
|
||||
'@typescript-eslint/no-shadow': 'warn',
|
||||
'@typescript-eslint/no-this-alias': 'warn',
|
||||
'@typescript-eslint/no-unnecessary-boolean-literal-compare': 'warn',
|
||||
'@typescript-eslint/no-unnecessary-type-assertion': 'warn',
|
||||
'@typescript-eslint/no-unsafe-argument': 'warn',
|
||||
'@typescript-eslint/no-unsafe-assignment': 'warn',
|
||||
'@typescript-eslint/no-unsafe-call': 'warn',
|
||||
'@typescript-eslint/no-unsafe-member-access': 'warn',
|
||||
'@typescript-eslint/no-unsafe-return': 'warn',
|
||||
'@typescript-eslint/no-unused-expressions': 'warn',
|
||||
'@typescript-eslint/no-unused-vars': 'warn',
|
||||
'@typescript-eslint/no-use-before-define': 'warn',
|
||||
'@typescript-eslint/no-var-requires': 'warn',
|
||||
'@typescript-eslint/prefer-nullish-coalescing': 'warn',
|
||||
'@typescript-eslint/prefer-optional-chain': 'warn',
|
||||
'@typescript-eslint/restrict-plus-operands': 'warn',
|
||||
'@typescript-eslint/restrict-template-expressions': 'warn',
|
||||
'@typescript-eslint/unbound-method': 'warn',
|
||||
'@typescript-eslint/ban-ts-comment': ['warn', { 'ts-ignore': true }],
|
||||
'@typescript-eslint/no-redundant-type-constituents': 'warn',
|
||||
'@typescript-eslint/no-base-to-string': 'warn',
|
||||
'@typescript-eslint/no-explicit-any': 'warn',
|
||||
'@typescript-eslint/no-unsafe-enum-comparison': 'warn',
|
||||
},
|
||||
};
|
28
packages/@n8n/chat/.gitignore
vendored
Normal file
28
packages/@n8n/chat/.gitignore
vendored
Normal file
|
@ -0,0 +1,28 @@
|
|||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
dist-ssr
|
||||
coverage
|
||||
*.local
|
||||
|
||||
/cypress/videos/
|
||||
/cypress/screenshots/
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
5
packages/@n8n/chat/.np-config.json
Normal file
5
packages/@n8n/chat/.np-config.json
Normal file
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"yarn": false,
|
||||
"tests": false,
|
||||
"contents": "./dist"
|
||||
}
|
27
packages/@n8n/chat/.storybook/main.ts
Normal file
27
packages/@n8n/chat/.storybook/main.ts
Normal file
|
@ -0,0 +1,27 @@
|
|||
import type { StorybookConfig } from '@storybook/vue3-vite';
|
||||
|
||||
import { join, dirname } from 'path';
|
||||
|
||||
/**
|
||||
* This function is used to resolve the absolute path of a package.
|
||||
* It is needed in projects that use Yarn PnP or are set up within a monorepo.
|
||||
*/
|
||||
function getAbsolutePath(value: string): any {
|
||||
return dirname(require.resolve(join(value, 'package.json')));
|
||||
}
|
||||
const config: StorybookConfig = {
|
||||
stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
|
||||
addons: [
|
||||
getAbsolutePath('@storybook/addon-links'),
|
||||
getAbsolutePath('@storybook/addon-essentials'),
|
||||
getAbsolutePath('@storybook/addon-interactions'),
|
||||
],
|
||||
framework: {
|
||||
name: getAbsolutePath('@storybook/vue3-vite'),
|
||||
options: {},
|
||||
},
|
||||
docs: {
|
||||
autodocs: 'tag',
|
||||
},
|
||||
};
|
||||
export default config;
|
4
packages/@n8n/chat/.storybook/preview.scss
Normal file
4
packages/@n8n/chat/.storybook/preview.scss
Normal file
|
@ -0,0 +1,4 @@
|
|||
html, body, #storybook-root, #n8n-chat {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
16
packages/@n8n/chat/.storybook/preview.ts
Normal file
16
packages/@n8n/chat/.storybook/preview.ts
Normal file
|
@ -0,0 +1,16 @@
|
|||
import type { Preview } from '@storybook/vue3';
|
||||
import './preview.scss';
|
||||
|
||||
const preview: Preview = {
|
||||
parameters: {
|
||||
actions: { argTypesRegex: '^on[A-Z].*' },
|
||||
controls: {
|
||||
matchers: {
|
||||
color: /(background|color)$/i,
|
||||
date: /Date$/,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default preview;
|
3
packages/@n8n/chat/.vscode/extensions.json
vendored
Normal file
3
packages/@n8n/chat/.vscode/extensions.json
vendored
Normal file
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"recommendations": ["Vue.volar", "Vue.vscode-typescript-vue-plugin"]
|
||||
}
|
85
packages/@n8n/chat/LICENSE.md
Normal file
85
packages/@n8n/chat/LICENSE.md
Normal file
|
@ -0,0 +1,85 @@
|
|||
# License
|
||||
|
||||
Portions of this software are licensed as follows:
|
||||
|
||||
- Content of branches other than the main branch (i.e. "master") are not licensed.
|
||||
- All source code files that contain ".ee." in their filename are licensed under the
|
||||
"n8n Enterprise License" defined in "LICENSE_EE.md".
|
||||
- All third party components incorporated into the n8n Software are licensed under the original license
|
||||
provided by the owner of the applicable component.
|
||||
- Content outside of the above mentioned files or restrictions is available under the "Sustainable Use
|
||||
License" as defined below.
|
||||
|
||||
## Sustainable Use License
|
||||
|
||||
Version 1.0
|
||||
|
||||
### Acceptance
|
||||
|
||||
By using the software, you agree to all of the terms and conditions below.
|
||||
|
||||
### Copyright License
|
||||
|
||||
The licensor grants you a non-exclusive, royalty-free, worldwide, non-sublicensable, non-transferable license
|
||||
to use, copy, distribute, make available, and prepare derivative works of the software, in each case subject
|
||||
to the limitations below.
|
||||
|
||||
### Limitations
|
||||
|
||||
You may use or modify the software only for your own internal business purposes or for non-commercial or
|
||||
personal use. You may distribute the software or provide it to others only if you do so free of charge for
|
||||
non-commercial purposes. You may not alter, remove, or obscure any licensing, copyright, or other notices of
|
||||
the licensor in the software. Any use of the licensor’s trademarks is subject to applicable law.
|
||||
|
||||
### Patents
|
||||
|
||||
The licensor grants you a license, under any patent claims the licensor can license, or becomes able to
|
||||
license, to make, have made, use, sell, offer for sale, import and have imported the software, in each case
|
||||
subject to the limitations and conditions in this license. This license does not cover any patent claims that
|
||||
you cause to be infringed by modifications or additions to the software. If you or your company make any
|
||||
written claim that the software infringes or contributes to infringement of any patent, your patent license
|
||||
for the software granted under these terms ends immediately. If your company makes such a claim, your patent
|
||||
license ends immediately for work on behalf of your company.
|
||||
|
||||
### Notices
|
||||
|
||||
You must ensure that anyone who gets a copy of any part of the software from you also gets a copy of these
|
||||
terms. If you modify the software, you must include in any modified copies of the software a prominent notice
|
||||
stating that you have modified the software.
|
||||
|
||||
### No Other Rights
|
||||
|
||||
These terms do not imply any licenses other than those expressly granted in these terms.
|
||||
|
||||
### Termination
|
||||
|
||||
If you use the software in violation of these terms, such use is not licensed, and your license will
|
||||
automatically terminate. If the licensor provides you with a notice of your violation, and you cease all
|
||||
violation of this license no later than 30 days after you receive that notice, your license will be reinstated
|
||||
retroactively. However, if you violate these terms after such reinstatement, any additional violation of these
|
||||
terms will cause your license to terminate automatically and permanently.
|
||||
|
||||
### No Liability
|
||||
|
||||
As far as the law allows, the software comes as is, without any warranty or condition, and the licensor will
|
||||
not be liable to you for any damages arising out of these terms or the use or nature of the software, under
|
||||
any kind of legal claim.
|
||||
|
||||
### Definitions
|
||||
|
||||
The “licensor” is the entity offering these terms.
|
||||
|
||||
The “software” is the software the licensor makes available under these terms, including any portion of it.
|
||||
|
||||
“You” refers to the individual or entity agreeing to these terms.
|
||||
|
||||
“Your company” is any legal entity, sole proprietorship, or other kind of organization that you work for, plus
|
||||
all organizations that have control over, are under the control of, or are under common control with that
|
||||
organization. Control means ownership of substantially all the assets of an entity, or the power to direct its
|
||||
management and policies by vote, contract, or otherwise. Control can be direct or indirect.
|
||||
|
||||
“Your license” is the license granted to you for the software under these terms.
|
||||
|
||||
“Use” means anything you do with the software requiring your license.
|
||||
|
||||
“Trademark” means trademarks, service marks, and similar rights.
|
227
packages/@n8n/chat/README.md
Normal file
227
packages/@n8n/chat/README.md
Normal file
|
@ -0,0 +1,227 @@
|
|||
# n8n Chat
|
||||
This is an embeddable Chat widget for n8n. It allows the execution of AI-Powered Workflows through a Chat window.
|
||||
|
||||
## Prerequisites
|
||||
Create a n8n workflow which you want to execute via chat. The workflow has to be triggered using a **Webhook** node and return data using the **Respond to Webhook** node.
|
||||
|
||||
Open the **Webhook** node and add your domain to the **Domain Allowlist** field. This makes sure that only requests from your domain are accepted.
|
||||
|
||||
[See example workflow](https://github.com/n8n-io/n8n/blob/ai-beta/packages/%40n8n/chat/resources/workflow.json)
|
||||
|
||||
> Make sure the workflow is **Active.**
|
||||
|
||||
### How it works
|
||||
Each Chat request is sent to the n8n Webhook endpoint, which then sends back a response.
|
||||
|
||||
Each request is accompanied by an `action` query parameter, where `action` can be one of:
|
||||
- `loadPreviousSession` - When the user opens the Chatbot again and the previous chat session should be loaded
|
||||
- `sendMessage` - When the user sends a message
|
||||
|
||||
We use the `Switch` node to handle the different actions.
|
||||
|
||||
## Installation
|
||||
|
||||
Open the **Webhook** node and replace `YOUR_PRODUCTION_WEBHOOK_URL` with your production URL. This is the URL that the Chat widget will use to send requests to.
|
||||
|
||||
### a. CDN Embed
|
||||
Add the following code to your HTML page.
|
||||
|
||||
```html
|
||||
<link href="https://cdn.jsdelivr.net/npm/@n8n/chat/style.css" rel="stylesheet" />
|
||||
<script type="module">
|
||||
import { createChat } from 'https://cdn.jsdelivr.net/npm/@n8n/chat/chat.bundle.es.js';
|
||||
|
||||
createChat({
|
||||
webhookUrl: 'YOUR_PRODUCTION_WEBHOOK_URL'
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
### b. Import Embed
|
||||
Install and save n8n Chat as a production dependency.
|
||||
|
||||
```sh
|
||||
npm install @n8n/chat
|
||||
```
|
||||
|
||||
Import the CSS and use the `createChat` function to initialize your Chat window.
|
||||
|
||||
```ts
|
||||
import '@n8n/chat/style.css';
|
||||
import { createChat } from '@n8n/chat';
|
||||
|
||||
createChat({
|
||||
webhookUrl: 'YOUR_PRODUCTION_WEBHOOK_URL'
|
||||
});
|
||||
```
|
||||
|
||||
##### Vue.js
|
||||
|
||||
```html
|
||||
<script lang="ts" setup>
|
||||
// App.vue
|
||||
import { onMounted } from 'vue';
|
||||
import '@n8n/chat/style.css';
|
||||
import { createChat } from '@n8n/chat';
|
||||
|
||||
onMounted(() => {
|
||||
createChat({
|
||||
webhookUrl: 'YOUR_PRODUCTION_WEBHOOK_URL'
|
||||
});
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<div></div>
|
||||
</template>
|
||||
```
|
||||
|
||||
##### React
|
||||
|
||||
```tsx
|
||||
// App.tsx
|
||||
import { useEffect } from 'react';
|
||||
import '@n8n/chat/style.css';
|
||||
import { createChat } from '@n8n/chat';
|
||||
|
||||
export const App = () => {
|
||||
useEffect(() => {
|
||||
createChat({
|
||||
webhookUrl: 'YOUR_PRODUCTION_WEBHOOK_URL'
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (<div></div>);
|
||||
};
|
||||
```
|
||||
|
||||
## Options
|
||||
The default options are:
|
||||
|
||||
```ts
|
||||
createChat({
|
||||
webhookUrl: '',
|
||||
webhookConfig: {
|
||||
method: 'POST',
|
||||
headers: {}
|
||||
},
|
||||
target: '#n8n-chat',
|
||||
mode: 'window',
|
||||
defaultLanguage: 'en',
|
||||
initialMessages: [
|
||||
'Hi there! 👋',
|
||||
'My name is Nathan. How can I assist you today?'
|
||||
],
|
||||
i18n: {
|
||||
en: {
|
||||
title: 'Hi there! 👋',
|
||||
subtitle: "Start a chat. We're here to help you 24/7.",
|
||||
footer: '',
|
||||
getStarted: 'New Conversation',
|
||||
inputPlaceholder: 'Type your question..',
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### `webhookUrl`
|
||||
- **Type**: `string`
|
||||
- **Required**: `true`
|
||||
- **Examples**:
|
||||
- `https://yourname.app.n8n.cloud/webhook/513107b3-6f3a-4a1e-af21-659f0ed14183`
|
||||
- `http://localhost:5678/webhook/513107b3-6f3a-4a1e-af21-659f0ed14183`
|
||||
- **Description**: The URL of the n8n Webhook endpoint. Should be the production URL.
|
||||
|
||||
### `webhookConfig`
|
||||
- **Type**: `{ method: string, headers: Record<string, string> }`
|
||||
- **Default**: `{ method: 'POST', headers: {} }`
|
||||
- **Description**: The configuration for the Webhook request.
|
||||
|
||||
### `target`
|
||||
- **Type**: `string`
|
||||
- **Default**: `'#n8n-chat'`
|
||||
- **Description**: The CSS selector of the element where the Chat window should be embedded.
|
||||
|
||||
### `mode`
|
||||
- **Type**: `'window' | 'fullscreen'`
|
||||
- **Default**: `'window'`
|
||||
- **Description**: The render mode of the Chat window.
|
||||
- In `window` mode, the Chat window will be embedded in the target element as a chat toggle button and a fixed size chat window.
|
||||
- In `fullscreen` mode, the Chat will take up the entire width and height of its target container.
|
||||
|
||||
### `defaultLanguage`
|
||||
- **Type**: `string`
|
||||
- **Default**: `'en'`
|
||||
- **Description**: The default language of the Chat window. Currently only `en` is supported.
|
||||
|
||||
### `i18n`
|
||||
- **Type**: `{ [key: string]: Record<string, string> }`
|
||||
- **Description**: The i18n configuration for the Chat window. Currently only `en` is supported.
|
||||
|
||||
### `initialMessages`
|
||||
- **Type**: `string[]`
|
||||
- **Description**: The initial messages to be displayed in the Chat window.
|
||||
|
||||
## Customization
|
||||
The Chat window is entirely customizable using CSS variables.
|
||||
|
||||
```css
|
||||
:root {
|
||||
--chat--color-primary: #e74266;
|
||||
--chat--color-primary-shade-50: #db4061;
|
||||
--chat--color-primary-shade-100: #cf3c5c;
|
||||
--chat--color-secondary: #20b69e;
|
||||
--chat--color-secondary-shade-50: #1ca08a;
|
||||
--chat--color-white: #ffffff;
|
||||
--chat--color-light: #f2f4f8;
|
||||
--chat--color-light-shade-50: #e6e9f1;
|
||||
--chat--color-light-shade-100: #c2c5cc;
|
||||
--chat--color-medium: #d2d4d9;
|
||||
--chat--color-dark: #101330;
|
||||
--chat--color-disabled: #777980;
|
||||
--chat--color-typing: #404040;
|
||||
|
||||
--chat--spacing: 1rem;
|
||||
--chat--border-radius: 0.25rem;
|
||||
--chat--transition-duration: 0.15s;
|
||||
|
||||
--chat--window--width: 400px;
|
||||
--chat--window--height: 600px;
|
||||
|
||||
--chat--textarea--height: 50px;
|
||||
|
||||
--chat--message--bot--background: var(--chat--color-white);
|
||||
--chat--message--bot--color: var(--chat--color-dark);
|
||||
--chat--message--user--background: var(--chat--color-secondary);
|
||||
--chat--message--user--color: var(--chat--color-white);
|
||||
--chat--message--pre--background: rgba(0, 0, 0, 0.05);
|
||||
|
||||
--chat--toggle--background: var(--chat--color-primary);
|
||||
--chat--toggle--hover--background: var(--chat--color-primary-shade-50);
|
||||
--chat--toggle--active--background: var(--chat--color-primary-shade-100);
|
||||
--chat--toggle--color: var(--chat--color-white);
|
||||
--chat--toggle--size: 64px;
|
||||
}
|
||||
```
|
||||
|
||||
## Caveats
|
||||
|
||||
### Fullscreen mode
|
||||
In fullscreen mode, the Chat window will take up the entire width and height of its target container. Make sure that the container has a set width and height.
|
||||
|
||||
```css
|
||||
html,
|
||||
body,
|
||||
#n8n-chat {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
```
|
||||
|
||||
## License
|
||||
n8n Chat is [fair-code](http://faircode.io) distributed under the
|
||||
[**Sustainable Use License**](https://github.com/n8n-io/n8n/blob/master/packages/cli/LICENSE.md).
|
||||
|
||||
Proprietary licenses are available for enterprise customers. [Get in touch](mailto:license@n8n.io)
|
||||
|
||||
Additional information about the license model can be found in the
|
||||
[docs](https://docs.n8n.io/reference/license/).
|
1
packages/@n8n/chat/env.d.ts
vendored
Normal file
1
packages/@n8n/chat/env.d.ts
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
/// <reference types="vite/client" />
|
13
packages/@n8n/chat/index.html
Normal file
13
packages/@n8n/chat/index.html
Normal file
|
@ -0,0 +1,13 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Vite App</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
83
packages/@n8n/chat/package.json
Normal file
83
packages/@n8n/chat/package.json
Normal file
|
@ -0,0 +1,83 @@
|
|||
{
|
||||
"name": "@n8n/chat",
|
||||
"version": "0.1.5",
|
||||
"scripts": {
|
||||
"dev": "npm run storybook",
|
||||
"build": "run-p type-check build:vite && npm run build:prepare",
|
||||
"build:vite": "vite build && npm run build:vite:full",
|
||||
"build:vite:full": "INCLUDE_VUE=true vite build",
|
||||
"build:prepare": "node scripts/postbuild.js",
|
||||
"build:pack": "node scripts/pack.js",
|
||||
"preview": "vite preview",
|
||||
"test:dev": "vitest",
|
||||
"test": "vitest run --coverage",
|
||||
"type-check": "vue-tsc --noEmit -p tsconfig.json --composite false",
|
||||
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore --ignore-path .eslintignore",
|
||||
"format": "prettier --write src/",
|
||||
"storybook": "storybook dev -p 6006 --no-open",
|
||||
"build:storybook": "storybook build",
|
||||
"release": "pnpm run build && cd dist && pnpm publish"
|
||||
},
|
||||
"main": "./chat.umd.cjs",
|
||||
"module": "./chat.es.js",
|
||||
"types": "./types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./chat.es.js",
|
||||
"require": "./chat.umd.cjs"
|
||||
},
|
||||
"./style.css": {
|
||||
"import": "./style.css",
|
||||
"require": "./style.css"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"highlight.js": "^11.8.0",
|
||||
"uuid": "^8.3.2",
|
||||
"vue": "^3.3.4",
|
||||
"vue-markdown-render": "^2.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@iconify-json/mdi": "^1.1.54",
|
||||
"@rushstack/eslint-patch": "^1.3.2",
|
||||
"@storybook/addon-essentials": "^7.4.0",
|
||||
"@storybook/addon-interactions": "^7.4.0",
|
||||
"@storybook/addon-links": "^7.4.0",
|
||||
"@storybook/blocks": "^7.4.0",
|
||||
"@storybook/testing-library": "^0.2.0",
|
||||
"@storybook/vue3": "^7.4.0",
|
||||
"@storybook/vue3-vite": "^7.4.0",
|
||||
"@testing-library/jest-dom": "^5.17.0",
|
||||
"@testing-library/vue": "^7.0.0",
|
||||
"@tsconfig/node18": "^18.2.0",
|
||||
"@types/jsdom": "^21.1.1",
|
||||
"@types/markdown-it": "^12.2.3",
|
||||
"@types/node": "^18.17.0",
|
||||
"@vitejs/plugin-vue": "^4.2.3",
|
||||
"@vue/eslint-config-prettier": "^8.0.0",
|
||||
"@vue/eslint-config-typescript": "^11.0.3",
|
||||
"@vue/test-utils": "^2.4.1",
|
||||
"@vue/tsconfig": "^0.4.0",
|
||||
"eslint": "^8.45.0",
|
||||
"eslint-plugin-vue": "^9.15.1",
|
||||
"jsdom": "^22.1.0",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"prettier": "^3.0.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"shelljs": "^0.8.5",
|
||||
"storybook": "^7.4.0",
|
||||
"typescript": "~5.1.6",
|
||||
"unplugin-icons": "^0.17.0",
|
||||
"vite": "^4.4.6",
|
||||
"vite-plugin-dts": "^3.6.0",
|
||||
"vitest": "^0.33.0",
|
||||
"vue-tsc": "^1.8.6"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/n8n-io/n8n.git"
|
||||
},
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"homepage": "https://n8n.io"
|
||||
}
|
BIN
packages/@n8n/chat/public/favicon.ico
Normal file
BIN
packages/@n8n/chat/public/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 4.2 KiB |
293
packages/@n8n/chat/resources/workflow.json
Normal file
293
packages/@n8n/chat/resources/workflow.json
Normal file
|
@ -0,0 +1,293 @@
|
|||
{
|
||||
"name": "AI Webhook Chat",
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": {
|
||||
"httpMethod": "POST",
|
||||
"path": "513107b3-6f3a-4a1e-af21-659f0ed14183",
|
||||
"responseMode": "responseNode",
|
||||
"options": {
|
||||
"domainAllowlist": "*.localhost"
|
||||
}
|
||||
},
|
||||
"id": "51ab2689-647d-4cff-9d6f-0ba4df45e904",
|
||||
"name": "Webhook",
|
||||
"type": "n8n-nodes-base.webhook",
|
||||
"typeVersion": 1,
|
||||
"position": [
|
||||
900,
|
||||
200
|
||||
],
|
||||
"webhookId": "513107b3-6f3a-4a1e-af21-659f0ed14183"
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"options": {}
|
||||
},
|
||||
"id": "3c7fd563-f610-41fa-b198-7fcf100e2815",
|
||||
"name": "Chat OpenAI",
|
||||
"type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
|
||||
"typeVersion": 1,
|
||||
"position": [
|
||||
1720,
|
||||
620
|
||||
],
|
||||
"credentials": {
|
||||
"openAiApi": {
|
||||
"id": "B5Fiv70Adfg6htxn",
|
||||
"name": "Alex's OpenAI Account"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"sessionKey": "={{ $json.body.sessionId }}"
|
||||
},
|
||||
"id": "ebc23ffa-3bcf-494f-bcb8-51a5fff91885",
|
||||
"name": "Window Buffer Memory",
|
||||
"type": "@n8n/n8n-nodes-langchain.memoryBufferWindow",
|
||||
"typeVersion": 1,
|
||||
"position": [
|
||||
1920,
|
||||
620
|
||||
]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"simplifyOutput": false
|
||||
},
|
||||
"id": "d6721a60-159b-4a93-ac6b-b81e16d9f16f",
|
||||
"name": "Memory Chat Retriever",
|
||||
"type": "@n8n/n8n-nodes-langchain.memoryChatRetriever",
|
||||
"typeVersion": 1,
|
||||
"position": [
|
||||
1780,
|
||||
-40
|
||||
]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"sessionKey": "={{ $json.body.sessionId }}"
|
||||
},
|
||||
"id": "347edc3a-1dda-4996-b778-dcdc447ecfd8",
|
||||
"name": "Memory Chat Retriever Window Buffer Memory",
|
||||
"type": "@n8n/n8n-nodes-langchain.memoryBufferWindow",
|
||||
"typeVersion": 1,
|
||||
"position": [
|
||||
1800,
|
||||
160
|
||||
]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"options": {
|
||||
"responseCode": 200,
|
||||
"responseHeaders": {
|
||||
"entries": [
|
||||
{
|
||||
"name": "sessionId",
|
||||
"value": "={{ $json.body.sessionId }}"
|
||||
},
|
||||
{
|
||||
"name": "Access-Control-Allow-Headers",
|
||||
"value": "*"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"id": "d229963e-e2f1-4381-87d2-47043bd6ccc7",
|
||||
"name": "Respond to Webhook",
|
||||
"type": "n8n-nodes-base.respondToWebhook",
|
||||
"typeVersion": 1,
|
||||
"position": [
|
||||
2460,
|
||||
220
|
||||
]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"dataType": "string",
|
||||
"value1": "={{ $json.body.action }}",
|
||||
"rules": {
|
||||
"rules": [
|
||||
{
|
||||
"value2": "loadPreviousSession"
|
||||
},
|
||||
{
|
||||
"value2": "sendMessage",
|
||||
"output": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"id": "fc4ad994-5f38-4dce-b1e5-397acc512687",
|
||||
"name": "Chatbot Action",
|
||||
"type": "n8n-nodes-base.switch",
|
||||
"typeVersion": 1,
|
||||
"position": [
|
||||
1320,
|
||||
200
|
||||
]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"jsCode": "const response = { data: [] };\n\nfor (const item of $input.all()) {\n response.data.push(item.json);\n}\n\nreturn {\n json: response,\n pairedItem: 0\n};"
|
||||
},
|
||||
"id": "e1a80bdc-411a-42df-88dd-36915b1ae8f4",
|
||||
"name": "Code",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"position": [
|
||||
2160,
|
||||
-40
|
||||
]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"text": "={{ $json.body.message }}",
|
||||
"options": {}
|
||||
},
|
||||
"id": "f28f5c00-c742-41d5-8ddb-f0f59ab111a3",
|
||||
"name": "Agent",
|
||||
"type": "@n8n/n8n-nodes-langchain.agent",
|
||||
"typeVersion": 1,
|
||||
"position": [
|
||||
1780,
|
||||
340
|
||||
]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"jsCode": "// Loop over input items and add a new field called 'myNewField' to the JSON of each one\nfor (const item of $input.all()) {\n item.json.body = JSON.parse(item.json.body);\n}\n\nreturn $input.all();"
|
||||
},
|
||||
"id": "415c071b-18b2-4ac5-8634-e3d939bf36ac",
|
||||
"name": "Transform request body",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"position": [
|
||||
1120,
|
||||
200
|
||||
]
|
||||
}
|
||||
],
|
||||
"pinData": {},
|
||||
"connections": {
|
||||
"Webhook": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Transform request body",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Memory Chat Retriever": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Code",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Memory Chat Retriever Window Buffer Memory": {
|
||||
"ai_memory": [
|
||||
[
|
||||
{
|
||||
"node": "Memory Chat Retriever",
|
||||
"type": "ai_memory",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Chatbot Action": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Memory Chat Retriever",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
"node": "Agent",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Code": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Respond to Webhook",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Chat OpenAI": {
|
||||
"ai_languageModel": [
|
||||
[
|
||||
{
|
||||
"node": "Agent",
|
||||
"type": "ai_languageModel",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Window Buffer Memory": {
|
||||
"ai_memory": [
|
||||
[
|
||||
{
|
||||
"node": "Agent",
|
||||
"type": "ai_memory",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Agent": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Respond to Webhook",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Transform request body": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Chatbot Action",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"active": true,
|
||||
"settings": {
|
||||
"executionOrder": "v1"
|
||||
},
|
||||
"versionId": "12c145a2-74bf-48b5-a87a-ba707949eaed",
|
||||
"id": "L3FlJuFOxZcHtoFT",
|
||||
"meta": {
|
||||
"instanceId": "374b43d8b8d6299cc777811a4ad220fc688ee2d54a308cfb0de4450a5233ca9e"
|
||||
},
|
||||
"tags": []
|
||||
}
|
11
packages/@n8n/chat/scripts/pack.js
Normal file
11
packages/@n8n/chat/scripts/pack.js
Normal file
|
@ -0,0 +1,11 @@
|
|||
const path = require('path');
|
||||
const shelljs = require('shelljs');
|
||||
|
||||
const rootDirPath = path.resolve(__dirname, '..');
|
||||
const distDirPath = path.resolve(rootDirPath, 'dist');
|
||||
|
||||
shelljs.cd(rootDirPath);
|
||||
shelljs.exec('npm run build');
|
||||
|
||||
shelljs.cd(distDirPath);
|
||||
shelljs.exec('npm pack');
|
16
packages/@n8n/chat/scripts/postbuild.js
Normal file
16
packages/@n8n/chat/scripts/postbuild.js
Normal file
|
@ -0,0 +1,16 @@
|
|||
const path = require('path');
|
||||
const shelljs = require('shelljs');
|
||||
|
||||
const rootDirPath = path.resolve(__dirname, '..');
|
||||
const n8nRootDirPath = path.resolve(rootDirPath, '..', '..', '..');
|
||||
const distDirPath = path.resolve(rootDirPath, 'dist');
|
||||
|
||||
const packageJsonFilePath = path.resolve(rootDirPath, 'package.json');
|
||||
const readmeFilePath = path.resolve(rootDirPath, 'README.md');
|
||||
const licenseFilePath = path.resolve(n8nRootDirPath, 'LICENSE.md');
|
||||
|
||||
shelljs.cp(packageJsonFilePath, distDirPath);
|
||||
shelljs.cp(readmeFilePath, distDirPath);
|
||||
shelljs.cp(licenseFilePath, distDirPath);
|
||||
|
||||
shelljs.mv(path.resolve(distDirPath, 'src'), path.resolve(distDirPath, 'types'));
|
23
packages/@n8n/chat/src/App.vue
Normal file
23
packages/@n8n/chat/src/App.vue
Normal file
|
@ -0,0 +1,23 @@
|
|||
<script lang="ts" setup>
|
||||
import { Chat, ChatWindow } from '@/components';
|
||||
import { computed, onMounted } from 'vue';
|
||||
import hljs from 'highlight.js/lib/core';
|
||||
import hljsXML from 'highlight.js/lib/languages/xml';
|
||||
import hljsJavascript from 'highlight.js/lib/languages/javascript';
|
||||
import { useOptions } from '@/composables';
|
||||
|
||||
defineProps({});
|
||||
|
||||
const { options } = useOptions();
|
||||
|
||||
const isFullscreen = computed<boolean>(() => options.mode === 'fullscreen');
|
||||
|
||||
onMounted(() => {
|
||||
hljs.registerLanguage('xml', hljsXML);
|
||||
hljs.registerLanguage('javascript', hljsJavascript);
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<Chat v-if="isFullscreen" class="n8n-chat" />
|
||||
<ChatWindow v-else class="n8n-chat" />
|
||||
</template>
|
43
packages/@n8n/chat/src/__stories__/App.stories.ts
Normal file
43
packages/@n8n/chat/src/__stories__/App.stories.ts
Normal file
|
@ -0,0 +1,43 @@
|
|||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
import type { StoryObj } from '@storybook/vue3';
|
||||
import type { ChatOptions } from '@/types';
|
||||
import { createChat } from '@/index';
|
||||
import { onMounted } from 'vue';
|
||||
|
||||
const webhookUrl = 'http://localhost:5678/webhook/513107b3-6f3a-4a1e-af21-659f0ed14183';
|
||||
|
||||
const meta = {
|
||||
title: 'Chat',
|
||||
render: (args: Partial<ChatOptions>) => ({
|
||||
setup() {
|
||||
onMounted(() => {
|
||||
createChat(args);
|
||||
});
|
||||
|
||||
return {};
|
||||
},
|
||||
template: '<div id="n8n-chat" />',
|
||||
}),
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
};
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Fullscreen: Story = {
|
||||
args: {
|
||||
webhookUrl,
|
||||
mode: 'fullscreen',
|
||||
} satisfies Partial<ChatOptions>,
|
||||
};
|
||||
|
||||
export const Windowed: Story = {
|
||||
args: {
|
||||
webhookUrl,
|
||||
mode: 'window',
|
||||
} satisfies Partial<ChatOptions>,
|
||||
};
|
223
packages/@n8n/chat/src/__tests__/index.spec.ts
Normal file
223
packages/@n8n/chat/src/__tests__/index.spec.ts
Normal file
|
@ -0,0 +1,223 @@
|
|||
import { fireEvent, waitFor } from '@testing-library/vue';
|
||||
import {
|
||||
createFetchResponse,
|
||||
createGetLatestMessagesResponse,
|
||||
createSendMessageResponse,
|
||||
getChatInputSendButton,
|
||||
getChatInputTextarea,
|
||||
getChatMessage,
|
||||
getChatMessageByText,
|
||||
getChatMessages,
|
||||
getChatMessageTyping,
|
||||
getChatWindowToggle,
|
||||
getChatWindowWrapper,
|
||||
getChatWrapper,
|
||||
getGetStartedButton,
|
||||
getMountingTarget,
|
||||
} from '@/__tests__/utils';
|
||||
import { createChat } from '@/index';
|
||||
import { useChat } from '@/composables';
|
||||
|
||||
describe('createChat()', () => {
|
||||
let app: ReturnType<typeof createChat>;
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
app.unmount();
|
||||
});
|
||||
|
||||
describe('mode', () => {
|
||||
it('should create fullscreen chat app with default options', () => {
|
||||
const fetchSpy = vi.spyOn(window, 'fetch');
|
||||
fetchSpy.mockImplementationOnce(createFetchResponse(createGetLatestMessagesResponse()));
|
||||
|
||||
app = createChat({
|
||||
mode: 'fullscreen',
|
||||
});
|
||||
|
||||
expect(getMountingTarget()).toBeVisible();
|
||||
expect(getChatWrapper()).toBeVisible();
|
||||
expect(getChatWindowWrapper()).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should create window chat app with default options', () => {
|
||||
const fetchSpy = vi.spyOn(window, 'fetch');
|
||||
fetchSpy.mockImplementationOnce(createFetchResponse(createGetLatestMessagesResponse()));
|
||||
|
||||
app = createChat({
|
||||
mode: 'window',
|
||||
});
|
||||
|
||||
expect(getMountingTarget()).toBeDefined();
|
||||
expect(getChatWindowWrapper()).toBeVisible();
|
||||
expect(getChatWrapper()).not.toBeVisible();
|
||||
});
|
||||
|
||||
it('should open window chat app using toggle button', async () => {
|
||||
const fetchSpy = vi.spyOn(window, 'fetch');
|
||||
fetchSpy.mockImplementationOnce(createFetchResponse(createGetLatestMessagesResponse()));
|
||||
|
||||
app = createChat();
|
||||
|
||||
expect(getMountingTarget()).toBeVisible();
|
||||
expect(getChatWindowWrapper()).toBeVisible();
|
||||
|
||||
const trigger = getChatWindowToggle();
|
||||
await fireEvent.click(trigger as HTMLElement);
|
||||
|
||||
expect(getChatWrapper()).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadPreviousMessages', () => {
|
||||
it('should load previous messages on mount', async () => {
|
||||
const fetchSpy = vi.spyOn(global, 'fetch');
|
||||
fetchSpy.mockImplementation(createFetchResponse(createGetLatestMessagesResponse()));
|
||||
|
||||
app = createChat({
|
||||
mode: 'fullscreen',
|
||||
});
|
||||
|
||||
const getStartedButton = getGetStartedButton();
|
||||
await fireEvent.click(getStartedButton as HTMLElement);
|
||||
|
||||
expect(fetchSpy.mock.calls[0][1]).toEqual(
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: {},
|
||||
body: expect.stringMatching(/"action":"loadPreviousSession"/),
|
||||
mode: 'cors',
|
||||
cache: 'no-cache',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('initialMessages', () => {
|
||||
it.each(['fullscreen', 'window'] as Array<'fullscreen' | 'window'>)(
|
||||
'should show initial default messages in %s mode',
|
||||
async (mode) => {
|
||||
const fetchSpy = vi.spyOn(window, 'fetch');
|
||||
fetchSpy.mockImplementationOnce(createFetchResponse(createGetLatestMessagesResponse()));
|
||||
|
||||
const initialMessages = ['Hello tester!', 'How are you?'];
|
||||
app = createChat({
|
||||
mode,
|
||||
initialMessages,
|
||||
});
|
||||
|
||||
if (mode === 'window') {
|
||||
const trigger = getChatWindowToggle();
|
||||
await fireEvent.click(trigger as HTMLElement);
|
||||
}
|
||||
|
||||
const getStartedButton = getGetStartedButton();
|
||||
await fireEvent.click(getStartedButton as HTMLElement);
|
||||
|
||||
expect(getChatMessages().length).toBe(initialMessages.length);
|
||||
expect(getChatMessageByText(initialMessages[0])).toBeInTheDocument();
|
||||
expect(getChatMessageByText(initialMessages[1])).toBeInTheDocument();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('sendMessage', () => {
|
||||
it.each(['window', 'fullscreen'] as Array<'fullscreen' | 'window'>)(
|
||||
'should send a message and render a text message in %s mode',
|
||||
async (mode) => {
|
||||
const input = 'Hello User World!';
|
||||
const output = 'Hello Bot World!';
|
||||
|
||||
const fetchSpy = vi.spyOn(window, 'fetch');
|
||||
fetchSpy
|
||||
.mockImplementationOnce(createFetchResponse(createGetLatestMessagesResponse))
|
||||
.mockImplementationOnce(createFetchResponse(createSendMessageResponse(output)));
|
||||
|
||||
app = createChat({
|
||||
mode,
|
||||
});
|
||||
|
||||
if (mode === 'window') {
|
||||
const trigger = getChatWindowToggle();
|
||||
await fireEvent.click(trigger as HTMLElement);
|
||||
}
|
||||
|
||||
expect(getChatMessageTyping()).not.toBeInTheDocument();
|
||||
|
||||
const getStartedButton = getGetStartedButton();
|
||||
await fireEvent.click(getStartedButton as HTMLElement);
|
||||
|
||||
expect(getChatMessages().length).toBe(2);
|
||||
|
||||
const textarea = getChatInputTextarea();
|
||||
const sendButton = getChatInputSendButton();
|
||||
await fireEvent.update(textarea as HTMLElement, input);
|
||||
expect(sendButton).not.toBeDisabled();
|
||||
await fireEvent.click(sendButton as HTMLElement);
|
||||
|
||||
expect(fetchSpy.mock.calls[1][1]).toEqual(
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: {},
|
||||
body: expect.stringMatching(/"action":"sendMessage"/),
|
||||
mode: 'cors',
|
||||
cache: 'no-cache',
|
||||
}),
|
||||
);
|
||||
expect(fetchSpy.mock.calls[1][1]?.body).toContain(`"${input}"`);
|
||||
|
||||
expect(getChatMessages().length).toBe(3);
|
||||
expect(getChatMessageByText(input)).toBeInTheDocument();
|
||||
expect(getChatMessageTyping()).toBeVisible();
|
||||
|
||||
await waitFor(() => expect(getChatMessageTyping()).not.toBeInTheDocument());
|
||||
expect(getChatMessageByText(output)).toBeInTheDocument();
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['fullscreen', 'window'] as Array<'fullscreen' | 'window'>)(
|
||||
'should send a message and render a code markdown message in %s mode',
|
||||
async (mode) => {
|
||||
const input = 'Teach me javascript!';
|
||||
const output = '# Code\n```js\nconsole.log("Hello World!");\n```';
|
||||
|
||||
const chatStore = useChat();
|
||||
console.log(chatStore);
|
||||
|
||||
const fetchSpy = vi.spyOn(window, 'fetch');
|
||||
fetchSpy
|
||||
.mockImplementationOnce(createFetchResponse(createGetLatestMessagesResponse))
|
||||
.mockImplementationOnce(createFetchResponse(createSendMessageResponse(output)));
|
||||
|
||||
app = createChat({
|
||||
mode,
|
||||
});
|
||||
|
||||
if (mode === 'window') {
|
||||
const trigger = getChatWindowToggle();
|
||||
await fireEvent.click(trigger as HTMLElement);
|
||||
}
|
||||
|
||||
const getStartedButton = getGetStartedButton();
|
||||
await fireEvent.click(getStartedButton as HTMLElement);
|
||||
|
||||
const textarea = getChatInputTextarea();
|
||||
const sendButton = getChatInputSendButton();
|
||||
await fireEvent.update(textarea as HTMLElement, input);
|
||||
await fireEvent.click(sendButton as HTMLElement);
|
||||
|
||||
expect(getChatMessageByText(input)).toBeInTheDocument();
|
||||
expect(getChatMessages().length).toBe(3);
|
||||
|
||||
await waitFor(() => expect(getChatMessageTyping()).not.toBeInTheDocument());
|
||||
|
||||
const lastMessage = getChatMessage(-1);
|
||||
expect(lastMessage).toBeInTheDocument();
|
||||
|
||||
expect(lastMessage.querySelector('h1')).toHaveTextContent('Code');
|
||||
expect(lastMessage.querySelector('code')).toHaveTextContent('console.log("Hello World!");');
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
1
packages/@n8n/chat/src/__tests__/setup.ts
Normal file
1
packages/@n8n/chat/src/__tests__/setup.ts
Normal file
|
@ -0,0 +1 @@
|
|||
import '@testing-library/jest-dom';
|
16
packages/@n8n/chat/src/__tests__/utils/create.ts
Normal file
16
packages/@n8n/chat/src/__tests__/utils/create.ts
Normal file
|
@ -0,0 +1,16 @@
|
|||
import { createChat } from '@/index';
|
||||
|
||||
export function createTestChat(options: Parameters<typeof createChat>[0] = {}): {
|
||||
unmount: () => void;
|
||||
container: Element;
|
||||
} {
|
||||
const app = createChat(options);
|
||||
|
||||
const container = app._container as Element;
|
||||
const unmount = () => app.unmount();
|
||||
|
||||
return {
|
||||
unmount,
|
||||
container,
|
||||
};
|
||||
}
|
18
packages/@n8n/chat/src/__tests__/utils/fetch.ts
Normal file
18
packages/@n8n/chat/src/__tests__/utils/fetch.ts
Normal file
|
@ -0,0 +1,18 @@
|
|||
import type { LoadPreviousSessionResponse, SendMessageResponse } from '@/types';
|
||||
|
||||
export function createFetchResponse<T>(data: T) {
|
||||
return async () =>
|
||||
({
|
||||
json: async () => new Promise<T>((resolve) => resolve(data)),
|
||||
}) as Response;
|
||||
}
|
||||
|
||||
export const createGetLatestMessagesResponse = (
|
||||
data: LoadPreviousSessionResponse['data'] = [],
|
||||
): LoadPreviousSessionResponse => ({ data });
|
||||
|
||||
export const createSendMessageResponse = (
|
||||
output: SendMessageResponse['output'],
|
||||
): SendMessageResponse => ({
|
||||
output,
|
||||
});
|
3
packages/@n8n/chat/src/__tests__/utils/index.ts
Normal file
3
packages/@n8n/chat/src/__tests__/utils/index.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
export * from './create';
|
||||
export * from './fetch';
|
||||
export * from './selectors';
|
53
packages/@n8n/chat/src/__tests__/utils/selectors.ts
Normal file
53
packages/@n8n/chat/src/__tests__/utils/selectors.ts
Normal file
|
@ -0,0 +1,53 @@
|
|||
import { screen } from '@testing-library/vue';
|
||||
import { defaultMountingTarget } from '@/constants';
|
||||
|
||||
export function getMountingTarget(target = defaultMountingTarget) {
|
||||
return document.querySelector(target);
|
||||
}
|
||||
|
||||
export function getChatWindowWrapper() {
|
||||
return document.querySelector('.chat-window-wrapper');
|
||||
}
|
||||
|
||||
export function getChatWindowToggle() {
|
||||
return document.querySelector('.chat-window-toggle');
|
||||
}
|
||||
|
||||
export function getChatWrapper() {
|
||||
return document.querySelector('.chat-wrapper');
|
||||
}
|
||||
|
||||
export function getChatMessages() {
|
||||
return document.querySelectorAll('.chat-message:not(.chat-message-typing)');
|
||||
}
|
||||
|
||||
export function getChatMessage(index: number) {
|
||||
const messages = getChatMessages();
|
||||
return index < 0 ? messages[messages.length + index] : messages[index];
|
||||
}
|
||||
|
||||
export function getChatMessageByText(text: string) {
|
||||
return screen.queryByText(text, {
|
||||
selector: '.chat-message:not(.chat-message-typing) .chat-message-markdown p',
|
||||
});
|
||||
}
|
||||
|
||||
export function getChatMessageTyping() {
|
||||
return document.querySelector('.chat-message-typing');
|
||||
}
|
||||
|
||||
export function getGetStartedButton() {
|
||||
return document.querySelector('.chat-get-started .chat-button');
|
||||
}
|
||||
|
||||
export function getChatInput() {
|
||||
return document.querySelector('.chat-input');
|
||||
}
|
||||
|
||||
export function getChatInputTextarea() {
|
||||
return document.querySelector('.chat-input textarea');
|
||||
}
|
||||
|
||||
export function getChatInputSendButton() {
|
||||
return document.querySelector('.chat-input .chat-input-send-button');
|
||||
}
|
64
packages/@n8n/chat/src/api/generic.ts
Normal file
64
packages/@n8n/chat/src/api/generic.ts
Normal file
|
@ -0,0 +1,64 @@
|
|||
async function getAccessToken() {
|
||||
return '';
|
||||
}
|
||||
|
||||
export async function authenticatedFetch<T>(...args: Parameters<typeof fetch>): Promise<T> {
|
||||
const accessToken = await getAccessToken();
|
||||
|
||||
const response = await fetch(args[0], {
|
||||
...args[1],
|
||||
mode: 'cors',
|
||||
cache: 'no-cache',
|
||||
headers: {
|
||||
...(accessToken ? { authorization: `Bearer ${accessToken}` } : {}),
|
||||
...args[1]?.headers,
|
||||
},
|
||||
});
|
||||
|
||||
return (await response.json()) as Promise<T>;
|
||||
}
|
||||
|
||||
export async function get<T>(
|
||||
url: string,
|
||||
query: Record<string, string> = {},
|
||||
options: RequestInit = {},
|
||||
) {
|
||||
let resolvedUrl = url;
|
||||
if (Object.keys(query).length > 0) {
|
||||
resolvedUrl = `${resolvedUrl}?${new URLSearchParams(query).toString()}`;
|
||||
}
|
||||
|
||||
return authenticatedFetch<T>(resolvedUrl, { ...options, method: 'GET' });
|
||||
}
|
||||
|
||||
export async function post<T>(url: string, body: object = {}, options: RequestInit = {}) {
|
||||
return authenticatedFetch<T>(url, {
|
||||
...options,
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
export async function put<T>(url: string, body: object = {}, options: RequestInit = {}) {
|
||||
return authenticatedFetch<T>(url, {
|
||||
...options,
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
export async function patch<T>(url: string, body: object = {}, options: RequestInit = {}) {
|
||||
return authenticatedFetch<T>(url, {
|
||||
...options,
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
export async function del<T>(url: string, body: object = {}, options: RequestInit = {}) {
|
||||
return authenticatedFetch<T>(url, {
|
||||
...options,
|
||||
method: 'DELETE',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
2
packages/@n8n/chat/src/api/index.ts
Normal file
2
packages/@n8n/chat/src/api/index.ts
Normal file
|
@ -0,0 +1,2 @@
|
|||
export * from './generic';
|
||||
export * from './message';
|
31
packages/@n8n/chat/src/api/message.ts
Normal file
31
packages/@n8n/chat/src/api/message.ts
Normal file
|
@ -0,0 +1,31 @@
|
|||
import { get, post } from '@/api/generic';
|
||||
import type { ChatOptions, LoadPreviousSessionResponse, SendMessageResponse } from '@/types';
|
||||
|
||||
export async function loadPreviousSession(sessionId: string, options: ChatOptions) {
|
||||
const method = options.webhookConfig?.method === 'POST' ? post : get;
|
||||
return method<LoadPreviousSessionResponse>(
|
||||
`${options.webhookUrl}`,
|
||||
{
|
||||
action: 'loadPreviousSession',
|
||||
sessionId,
|
||||
},
|
||||
{
|
||||
headers: options.webhookConfig?.headers,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function sendMessage(message: string, sessionId: string, options: ChatOptions) {
|
||||
const method = options.webhookConfig?.method === 'POST' ? post : get;
|
||||
return method<SendMessageResponse>(
|
||||
`${options.webhookUrl}`,
|
||||
{
|
||||
action: 'sendMessage',
|
||||
sessionId,
|
||||
message,
|
||||
},
|
||||
{
|
||||
headers: options.webhookConfig?.headers,
|
||||
},
|
||||
);
|
||||
}
|
41
packages/@n8n/chat/src/components/Button.vue
Normal file
41
packages/@n8n/chat/src/components/Button.vue
Normal file
|
@ -0,0 +1,41 @@
|
|||
<template>
|
||||
<button class="chat-button">
|
||||
<slot />
|
||||
</button>
|
||||
</template>
|
||||
<style lang="scss">
|
||||
.chat-button {
|
||||
display: inline-flex;
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
user-select: none;
|
||||
color: var(--chat--button--color, var(--chat--color-light));
|
||||
background-color: var(--chat--button--background, var(--chat--color-primary));
|
||||
border: 1px solid transparent;
|
||||
padding: var(--chat--button--padding, calc(var(--chat--spacing) * 1 / 2) var(--chat--spacing));
|
||||
font-size: 1rem;
|
||||
line-height: 1.5;
|
||||
border-radius: var(--chat--button--border-radius, var(--chat--border-radius));
|
||||
transition:
|
||||
color var(--chat--transition-duration) ease-in-out,
|
||||
background-color var(--chat--transition-duration) ease-in-out,
|
||||
border-color var(--chat--transition-duration) ease-in-out,
|
||||
box-shadow var(--chat--transition-duration) ease-in-out;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: var(--chat--button--hover--color, var(--chat--color-light));
|
||||
background-color: var(--chat--button--hover--background, var(--chat--color-primary-shade-50));
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: 0;
|
||||
box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.65;
|
||||
}
|
||||
}
|
||||
</style>
|
48
packages/@n8n/chat/src/components/Chat.vue
Normal file
48
packages/@n8n/chat/src/components/Chat.vue
Normal file
|
@ -0,0 +1,48 @@
|
|||
<script setup lang="ts">
|
||||
import Layout from '@/components/Layout.vue';
|
||||
import GetStarted from '@/components/GetStarted.vue';
|
||||
import GetStartedFooter from '@/components/GetStartedFooter.vue';
|
||||
import MessagesList from '@/components/MessagesList.vue';
|
||||
import Input from '@/components/Input.vue';
|
||||
import { nextTick, onMounted } from 'vue';
|
||||
import { useI18n, useChat } from '@/composables';
|
||||
import { chatEventBus } from '@/event-buses';
|
||||
|
||||
const { t } = useI18n();
|
||||
const chatStore = useChat();
|
||||
|
||||
const { messages, currentSessionId } = chatStore;
|
||||
|
||||
async function initialize() {
|
||||
await chatStore.loadPreviousSession();
|
||||
void nextTick(() => {
|
||||
chatEventBus.emit('scrollToBottom');
|
||||
});
|
||||
}
|
||||
|
||||
async function getStarted() {
|
||||
void chatStore.startNewSession();
|
||||
void nextTick(() => {
|
||||
chatEventBus.emit('scrollToBottom');
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void initialize();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Layout class="chat-wrapper">
|
||||
<template #header v-if="!currentSessionId">
|
||||
<h1>{{ t('title') }}</h1>
|
||||
<p>{{ t('subtitle') }}</p>
|
||||
</template>
|
||||
<GetStarted v-if="!currentSessionId" @click:button="getStarted" />
|
||||
<MessagesList v-else :messages="messages" />
|
||||
<template #footer>
|
||||
<Input v-if="currentSessionId" />
|
||||
<GetStartedFooter v-else />
|
||||
</template>
|
||||
</Layout>
|
||||
</template>
|
125
packages/@n8n/chat/src/components/ChatWindow.vue
Normal file
125
packages/@n8n/chat/src/components/ChatWindow.vue
Normal file
|
@ -0,0 +1,125 @@
|
|||
<script lang="ts" setup>
|
||||
// eslint-disable-next-line import/no-unresolved
|
||||
import IconChat from 'virtual:icons/mdi/chat';
|
||||
// eslint-disable-next-line import/no-unresolved
|
||||
import IconChevronDown from 'virtual:icons/mdi/chevron-down';
|
||||
import Chat from '@/components/Chat.vue';
|
||||
import { nextTick, ref } from 'vue';
|
||||
import { chatEventBus } from '@/event-buses';
|
||||
|
||||
const isOpen = ref(false);
|
||||
|
||||
function toggle() {
|
||||
isOpen.value = !isOpen.value;
|
||||
|
||||
if (isOpen.value) {
|
||||
void nextTick(() => {
|
||||
chatEventBus.emit('scrollToBottom');
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="chat-window-wrapper">
|
||||
<Transition name="chat-window-transition">
|
||||
<div class="chat-window" v-show="isOpen">
|
||||
<Chat />
|
||||
</div>
|
||||
</Transition>
|
||||
<div class="chat-window-toggle" @click="toggle">
|
||||
<Transition name="chat-window-toggle-transition" mode="out-in">
|
||||
<IconChat v-if="!isOpen" height="32" width="32" />
|
||||
<IconChevronDown v-else height="32" width="32" />
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.chat-window-wrapper {
|
||||
position: fixed;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
bottom: var(--chat--window--bottom, var(--chat--spacing));
|
||||
right: var(--chat--window--right, var(--chat--spacing));
|
||||
z-index: var(--chat--window--z-index, 9999);
|
||||
|
||||
max-width: calc(100% - var(--chat--window--right, var(--chat--spacing)) * 2);
|
||||
max-height: calc(100% - var(--chat--window--bottom, var(--chat--spacing)) * 2);
|
||||
|
||||
.chat-window {
|
||||
display: flex;
|
||||
width: var(--chat--window--width);
|
||||
height: var(--chat--window--height);
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
border: var(--chat--window--border, 1px solid var(--chat--color-light-shade-100));
|
||||
border-radius: var(--chat--window--border-radius, var(--chat--border-radius));
|
||||
margin-bottom: var(--chat--window--margin-bottom, var(--chat--spacing));
|
||||
overflow: hidden;
|
||||
transform-origin: bottom right;
|
||||
|
||||
.chat-layout {
|
||||
width: auto;
|
||||
height: auto;
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-window-toggle {
|
||||
flex: 0 0 auto;
|
||||
background: var(--chat--toggle--background);
|
||||
color: var(--chat--toggle--color);
|
||||
cursor: pointer;
|
||||
width: var(--chat--toggle--width, var(--chat--toggle--size));
|
||||
height: var(--chat--toggle--height, var(--chat--toggle--size));
|
||||
border-radius: var(--chat--toggle--border-radius, 50%);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-left: auto;
|
||||
transition:
|
||||
transform var(--chat--transition-duration) ease,
|
||||
background var(--chat--transition-duration) ease;
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
transform: scale(1.05);
|
||||
background: var(--chat--toggle--hover--background);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.95);
|
||||
background: var(--chat--toggle--active--background);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chat-window-transition {
|
||||
&-enter-active,
|
||||
&-leave-active {
|
||||
transition:
|
||||
transform var(--chat--transition-duration) ease,
|
||||
opacity var(--chat--transition-duration) ease;
|
||||
}
|
||||
|
||||
&-enter-from,
|
||||
&-leave-to {
|
||||
transform: scale(0);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-window-toggle-transition {
|
||||
&-enter-active,
|
||||
&-leave-active {
|
||||
transition: opacity var(--chat--transition-duration) ease;
|
||||
}
|
||||
|
||||
&-enter-from,
|
||||
&-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
24
packages/@n8n/chat/src/components/GetStarted.vue
Normal file
24
packages/@n8n/chat/src/components/GetStarted.vue
Normal file
|
@ -0,0 +1,24 @@
|
|||
<script setup lang="ts">
|
||||
import Button from '@/components/Button.vue';
|
||||
import { useI18n } from '@/composables';
|
||||
|
||||
const { t } = useI18n();
|
||||
</script>
|
||||
<template>
|
||||
<div class="chat-get-started">
|
||||
<Button @click="$emit('click:button')">
|
||||
{{ t('getStarted') }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.chat-get-started {
|
||||
padding-top: var(--chat--spacing);
|
||||
padding-bottom: var(--chat--spacing);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
20
packages/@n8n/chat/src/components/GetStartedFooter.vue
Normal file
20
packages/@n8n/chat/src/components/GetStartedFooter.vue
Normal file
|
@ -0,0 +1,20 @@
|
|||
<script setup lang="ts">
|
||||
import { useI18n } from '@/composables';
|
||||
import PoweredBy from '@/components/PoweredBy.vue';
|
||||
|
||||
const { t, te } = useI18n();
|
||||
</script>
|
||||
<template>
|
||||
<div class="chat-get-started-footer">
|
||||
<div v-if="te('footer')">
|
||||
{{ t('footer') }}
|
||||
</div>
|
||||
<PoweredBy />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.chat-get-started-footer {
|
||||
padding: var(--chat--spacing);
|
||||
}
|
||||
</style>
|
93
packages/@n8n/chat/src/components/Input.vue
Normal file
93
packages/@n8n/chat/src/components/Input.vue
Normal file
|
@ -0,0 +1,93 @@
|
|||
<script setup lang="ts">
|
||||
// eslint-disable-next-line import/no-unresolved
|
||||
import IconSend from 'virtual:icons/mdi/send';
|
||||
import { useI18n, useChat } from '@/composables';
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
const chatStore = useChat();
|
||||
const { waitingForResponse } = chatStore;
|
||||
const { t } = useI18n();
|
||||
|
||||
const input = ref('');
|
||||
|
||||
const isSubmitDisabled = computed(() => {
|
||||
return input.value === '' || waitingForResponse.value;
|
||||
});
|
||||
|
||||
async function onSubmit(event: MouseEvent | KeyboardEvent) {
|
||||
event.preventDefault();
|
||||
|
||||
if (isSubmitDisabled.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const messageText = input.value;
|
||||
input.value = '';
|
||||
await chatStore.sendMessage(messageText);
|
||||
}
|
||||
|
||||
async function onSubmitKeydown(event: KeyboardEvent) {
|
||||
if (event.shiftKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
await onSubmit(event);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="chat-input">
|
||||
<textarea
|
||||
v-model="input"
|
||||
rows="1"
|
||||
:placeholder="t('inputPlaceholder')"
|
||||
@keydown.enter="onSubmitKeydown"
|
||||
/>
|
||||
<button :disabled="isSubmitDisabled" class="chat-input-send-button" @click="onSubmit">
|
||||
<IconSend height="32" width="32" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.chat-input {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
|
||||
textarea {
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
width: 100%;
|
||||
border: 0;
|
||||
padding: var(--chat--spacing);
|
||||
max-height: var(--chat--textarea--height);
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.chat-input-send-button {
|
||||
height: var(--chat--textarea--height);
|
||||
width: var(--chat--textarea--height);
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
color: var(--chat--color-secondary);
|
||||
border: 0;
|
||||
font-size: 24px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: color var(--chat--transition-duration) ease;
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
color: var(--chat--color-secondary-shade-50);
|
||||
}
|
||||
|
||||
&[disabled] {
|
||||
cursor: default;
|
||||
color: var(--chat--color-disabled);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
82
packages/@n8n/chat/src/components/Layout.vue
Normal file
82
packages/@n8n/chat/src/components/Layout.vue
Normal file
|
@ -0,0 +1,82 @@
|
|||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
import { chatEventBus } from '@/event-buses';
|
||||
|
||||
const chatBodyRef = ref<HTMLElement | null>(null);
|
||||
|
||||
function scrollToBottom() {
|
||||
const element = chatBodyRef.value as HTMLElement;
|
||||
if (element) {
|
||||
element.scrollTop = element.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
chatEventBus.on('scrollToBottom', scrollToBottom);
|
||||
window.addEventListener('resize', scrollToBottom);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
chatEventBus.off('scrollToBottom', scrollToBottom);
|
||||
window.removeEventListener('resize', scrollToBottom);
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<main class="chat-layout">
|
||||
<div v-if="$slots.header" class="chat-header">
|
||||
<slot name="header" />
|
||||
</div>
|
||||
<div v-if="$slots.default" class="chat-body" ref="chatBodyRef">
|
||||
<slot />
|
||||
</div>
|
||||
<div v-if="$slots.footer" class="chat-footer">
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.chat-layout {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
overflow-y: auto;
|
||||
flex-direction: column;
|
||||
font-family: var(
|
||||
--chat--font-family,
|
||||
(
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
'Segoe UI',
|
||||
Roboto,
|
||||
Oxygen-Sans,
|
||||
Ubuntu,
|
||||
Cantarell,
|
||||
'Helvetica Neue',
|
||||
sans-serif
|
||||
)
|
||||
);
|
||||
|
||||
.chat-header {
|
||||
padding: var(--chat--header--padding, var(--chat--spacing));
|
||||
background: var(--chat--header--background, var(--chat--color-dark));
|
||||
color: var(--chat--header--color, var(--chat--color-light));
|
||||
}
|
||||
|
||||
.chat-body {
|
||||
background: var(--chat--body--background, var(--chat--color-light));
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
.chat-footer {
|
||||
border-top: 1px solid var(--chat--color-light-shade-100);
|
||||
background: var(--chat--footer--background, var(--chat--color-light));
|
||||
color: var(--chat--footer--color, var(--chat--color-dark));
|
||||
}
|
||||
}
|
||||
</style>
|
97
packages/@n8n/chat/src/components/Message.vue
Normal file
97
packages/@n8n/chat/src/components/Message.vue
Normal file
|
@ -0,0 +1,97 @@
|
|||
<script lang="ts" setup>
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
import type { ChatMessage } from '@/types';
|
||||
import type { PropType } from 'vue';
|
||||
import { computed, toRefs } from 'vue';
|
||||
import VueMarkdown from 'vue-markdown-render';
|
||||
import hljs from 'highlight.js/lib/core';
|
||||
|
||||
const props = defineProps({
|
||||
message: {
|
||||
type: Object as PropType<ChatMessage>,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { message } = toRefs(props);
|
||||
|
||||
const classes = computed(() => {
|
||||
return {
|
||||
'chat-message-from-user': message.value.sender === 'user',
|
||||
'chat-message-from-bot': message.value.sender === 'bot',
|
||||
};
|
||||
});
|
||||
|
||||
const markdownOptions = {
|
||||
highlight(str: string, lang: string) {
|
||||
if (lang && hljs.getLanguage(lang)) {
|
||||
try {
|
||||
return hljs.highlight(str, { language: lang }).value;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return ''; // use external default escaping
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<template>
|
||||
<div class="chat-message" :class="classes">
|
||||
<slot>
|
||||
<vue-markdown
|
||||
class="chat-message-markdown"
|
||||
:source="message.text"
|
||||
:options="markdownOptions"
|
||||
/>
|
||||
</slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.chat-message {
|
||||
display: block;
|
||||
max-width: 80%;
|
||||
padding: var(--chat--message--padding, var(--chat--spacing));
|
||||
border-radius: var(--chat--message--border-radius, var(--chat--border-radius));
|
||||
|
||||
+ .chat-message {
|
||||
margin-top: var(--chat--message--margin-bottom, calc(var(--chat--spacing) * 0.5));
|
||||
}
|
||||
|
||||
&.chat-message-from-bot {
|
||||
background-color: var(--chat--message--bot--background);
|
||||
color: var(--chat--message--bot--color);
|
||||
border-bottom-left-radius: 0;
|
||||
}
|
||||
|
||||
&.chat-message-from-user {
|
||||
background-color: var(--chat--message--user--background);
|
||||
color: var(--chat--message--user--color);
|
||||
margin-left: auto;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
|
||||
> .chat-message-markdown {
|
||||
display: block;
|
||||
box-sizing: border-box;
|
||||
|
||||
> *:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
> *:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
pre {
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
box-sizing: border-box;
|
||||
padding: var(--chat--spacing);
|
||||
background: var(--chat--message--pre--background);
|
||||
border-radius: var(--chat--border-radius);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
109
packages/@n8n/chat/src/components/MessageTyping.vue
Normal file
109
packages/@n8n/chat/src/components/MessageTyping.vue
Normal file
|
@ -0,0 +1,109 @@
|
|||
<script lang="ts" setup>
|
||||
import type { ChatMessage } from '@/types';
|
||||
import { Message } from './index';
|
||||
import type { PropType } from 'vue';
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
animation: {
|
||||
type: String as PropType<'bouncing' | 'scaling'>,
|
||||
default: 'bouncing',
|
||||
},
|
||||
});
|
||||
|
||||
const message: ChatMessage = {
|
||||
id: 'typing',
|
||||
text: '',
|
||||
sender: 'bot',
|
||||
createdAt: '',
|
||||
};
|
||||
|
||||
const classes = computed(() => {
|
||||
return {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
'chat-message-typing': true,
|
||||
[`chat-message-typing-animation-${props.animation}`]: true,
|
||||
};
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<Message :class="classes" :message="message">
|
||||
<div class="chat-message-typing-body">
|
||||
<span class="chat-message-typing-circle"></span>
|
||||
<span class="chat-message-typing-circle"></span>
|
||||
<span class="chat-message-typing-circle"></span>
|
||||
</div>
|
||||
</Message>
|
||||
</template>
|
||||
<style lang="scss">
|
||||
.chat-message-typing {
|
||||
max-width: 80px;
|
||||
|
||||
&.chat-message-typing-animation-scaling .chat-message-typing-circle {
|
||||
animation: chat-message-typing-animation-scaling 800ms ease-in-out infinite;
|
||||
animation-delay: 3600ms;
|
||||
}
|
||||
|
||||
&.chat-message-typing-animation-bouncing .chat-message-typing-circle {
|
||||
animation: chat-message-typing-animation-bouncing 800ms ease-in-out infinite;
|
||||
animation-delay: 3600ms;
|
||||
}
|
||||
|
||||
.chat-message-typing-body {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.chat-message-typing-circle {
|
||||
display: block;
|
||||
height: 10px;
|
||||
width: 10px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--chat--color-typing);
|
||||
margin: 3px;
|
||||
|
||||
&:nth-child(1) {
|
||||
animation-delay: 0ms;
|
||||
}
|
||||
|
||||
&:nth-child(2) {
|
||||
animation-delay: 333ms;
|
||||
}
|
||||
|
||||
&:nth-child(3) {
|
||||
animation-delay: 666ms;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes chat-message-typing-animation-scaling {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
}
|
||||
33% {
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.4);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes chat-message-typing-animation-bouncing {
|
||||
0% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
33% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
</style>
|
37
packages/@n8n/chat/src/components/MessagesList.vue
Normal file
37
packages/@n8n/chat/src/components/MessagesList.vue
Normal file
|
@ -0,0 +1,37 @@
|
|||
<script lang="ts" setup>
|
||||
import type { PropType } from 'vue';
|
||||
import Message from '@/components/Message.vue';
|
||||
import type { ChatMessage } from '@/types';
|
||||
import MessageTyping from '@/components/MessageTyping.vue';
|
||||
import { useChat } from '@/composables';
|
||||
|
||||
defineProps({
|
||||
messages: {
|
||||
type: Array as PropType<ChatMessage[]>,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const chatStore = useChat();
|
||||
|
||||
const { initialMessages, waitingForResponse } = chatStore;
|
||||
</script>
|
||||
<template>
|
||||
<div class="chat-messages-list">
|
||||
<Message
|
||||
v-for="initialMessage in initialMessages"
|
||||
:key="initialMessage.id"
|
||||
:message="initialMessage"
|
||||
/>
|
||||
<Message v-for="message in messages" :key="message.id" :message="message" />
|
||||
<MessageTyping v-if="waitingForResponse" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.chat-messages-list {
|
||||
margin-top: auto;
|
||||
display: block;
|
||||
padding: var(--chat--messages-list--padding, var(--chat--spacing));
|
||||
}
|
||||
</style>
|
17
packages/@n8n/chat/src/components/PoweredBy.vue
Normal file
17
packages/@n8n/chat/src/components/PoweredBy.vue
Normal file
|
@ -0,0 +1,17 @@
|
|||
<template>
|
||||
<div class="chat-powered-by">
|
||||
Powered by
|
||||
<a href="https://n8n.io?utm_source=n8n-external&utm_medium=widget-powered-by">n8n</a>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.chat-powered-by {
|
||||
text-align: center;
|
||||
|
||||
a {
|
||||
color: var(--chat--color-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
</style>
|
10
packages/@n8n/chat/src/components/index.ts
Normal file
10
packages/@n8n/chat/src/components/index.ts
Normal file
|
@ -0,0 +1,10 @@
|
|||
export { default as Button } from './Button.vue';
|
||||
export { default as Chat } from './Chat.vue';
|
||||
export { default as ChatWindow } from './ChatWindow.vue';
|
||||
export { default as GetStarted } from './GetStarted.vue';
|
||||
export { default as GetStartedFooter } from './GetStartedFooter.vue';
|
||||
export { default as Input } from './Input.vue';
|
||||
export { default as Layout } from './Layout.vue';
|
||||
export { default as Message } from './Message.vue';
|
||||
export { default as MessagesList } from './MessagesList.vue';
|
||||
export { default as PoweredBy } from './PoweredBy.vue';
|
3
packages/@n8n/chat/src/composables/index.ts
Normal file
3
packages/@n8n/chat/src/composables/index.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
export * from './useChat';
|
||||
export * from './useI18n';
|
||||
export * from './useOptions';
|
7
packages/@n8n/chat/src/composables/useChat.ts
Normal file
7
packages/@n8n/chat/src/composables/useChat.ts
Normal file
|
@ -0,0 +1,7 @@
|
|||
import { inject } from 'vue';
|
||||
import { ChatSymbol } from '@/constants';
|
||||
import type { Chat } from '@/types';
|
||||
|
||||
export function useChat() {
|
||||
return inject(ChatSymbol) as Chat;
|
||||
}
|
16
packages/@n8n/chat/src/composables/useI18n.ts
Normal file
16
packages/@n8n/chat/src/composables/useI18n.ts
Normal file
|
@ -0,0 +1,16 @@
|
|||
import { useOptions } from '@/composables/useOptions';
|
||||
|
||||
export function useI18n() {
|
||||
const { options } = useOptions();
|
||||
const language = options?.defaultLanguage ?? 'en';
|
||||
|
||||
function t(key: string): string {
|
||||
return options?.i18n?.[language]?.[key] ?? key;
|
||||
}
|
||||
|
||||
function te(key: string): boolean {
|
||||
return !!options?.i18n?.[language]?.[key];
|
||||
}
|
||||
|
||||
return { t, te };
|
||||
}
|
11
packages/@n8n/chat/src/composables/useOptions.ts
Normal file
11
packages/@n8n/chat/src/composables/useOptions.ts
Normal file
|
@ -0,0 +1,11 @@
|
|||
import { inject } from 'vue';
|
||||
import { ChatOptionsSymbol } from '@/constants';
|
||||
import type { ChatOptions } from '@/types';
|
||||
|
||||
export function useOptions() {
|
||||
const options = inject(ChatOptionsSymbol) as ChatOptions;
|
||||
|
||||
return {
|
||||
options,
|
||||
};
|
||||
}
|
25
packages/@n8n/chat/src/constants/defaults.ts
Normal file
25
packages/@n8n/chat/src/constants/defaults.ts
Normal file
|
@ -0,0 +1,25 @@
|
|||
import type { ChatOptions } from '@/types';
|
||||
|
||||
export const defaultOptions: ChatOptions = {
|
||||
webhookUrl: 'http://localhost:5678',
|
||||
webhookConfig: {
|
||||
method: 'POST',
|
||||
headers: {},
|
||||
},
|
||||
target: '#n8n-chat',
|
||||
mode: 'window',
|
||||
defaultLanguage: 'en',
|
||||
initialMessages: ['Hi there! 👋', 'My name is Nathan. How can I assist you today?'],
|
||||
i18n: {
|
||||
en: {
|
||||
title: 'Hi there! 👋',
|
||||
subtitle: "Start a chat. We're here to help you 24/7.",
|
||||
footer: '',
|
||||
getStarted: 'New Conversation',
|
||||
inputPlaceholder: 'Type your question..',
|
||||
},
|
||||
},
|
||||
theme: {},
|
||||
};
|
||||
|
||||
export const defaultMountingTarget = '#n8n-chat';
|
3
packages/@n8n/chat/src/constants/index.ts
Normal file
3
packages/@n8n/chat/src/constants/index.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
export * from './defaults';
|
||||
export * from './localStorage';
|
||||
export * from './symbols';
|
2
packages/@n8n/chat/src/constants/localStorage.ts
Normal file
2
packages/@n8n/chat/src/constants/localStorage.ts
Normal file
|
@ -0,0 +1,2 @@
|
|||
export const localStorageNamespace = 'n8n-chat';
|
||||
export const localStorageSessionIdKey = `${localStorageNamespace}/sessionId`;
|
8
packages/@n8n/chat/src/constants/symbols.ts
Normal file
8
packages/@n8n/chat/src/constants/symbols.ts
Normal file
|
@ -0,0 +1,8 @@
|
|||
import type { InjectionKey } from 'vue';
|
||||
import type { Chat, ChatOptions } from '@/types';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
export const ChatSymbol = 'Chat' as unknown as InjectionKey<Chat>;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
export const ChatOptionsSymbol = 'ChatOptions' as unknown as InjectionKey<ChatOptions>;
|
3
packages/@n8n/chat/src/event-buses/chatEventBus.ts
Normal file
3
packages/@n8n/chat/src/event-buses/chatEventBus.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
import { createEventBus } from '@/utils';
|
||||
|
||||
export const chatEventBus = createEventBus();
|
1
packages/@n8n/chat/src/event-buses/index.ts
Normal file
1
packages/@n8n/chat/src/event-buses/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export * from './chatEventBus';
|
42
packages/@n8n/chat/src/index.ts
Normal file
42
packages/@n8n/chat/src/index.ts
Normal file
|
@ -0,0 +1,42 @@
|
|||
import './main.scss';
|
||||
|
||||
import { createApp } from 'vue';
|
||||
import App from './App.vue';
|
||||
import type { ChatOptions } from '@/types';
|
||||
import { defaultMountingTarget, defaultOptions } from '@/constants';
|
||||
import { createDefaultMountingTarget } from '@/utils';
|
||||
import { ChatPlugin } from '@/plugins';
|
||||
|
||||
export function createChat(options?: Partial<ChatOptions>) {
|
||||
const resolvedOptions: ChatOptions = {
|
||||
...defaultOptions,
|
||||
...options,
|
||||
webhookConfig: {
|
||||
...defaultOptions.webhookConfig,
|
||||
...options?.webhookConfig,
|
||||
},
|
||||
i18n: {
|
||||
...defaultOptions.i18n,
|
||||
...options?.i18n,
|
||||
en: {
|
||||
...defaultOptions.i18n?.en,
|
||||
...options?.i18n?.en,
|
||||
},
|
||||
},
|
||||
theme: {
|
||||
...defaultOptions.theme,
|
||||
...options?.theme,
|
||||
},
|
||||
};
|
||||
|
||||
const mountingTarget = resolvedOptions.target ?? defaultMountingTarget;
|
||||
if (typeof mountingTarget === 'string') {
|
||||
createDefaultMountingTarget(mountingTarget);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
const app = createApp(App);
|
||||
app.use(ChatPlugin, resolvedOptions);
|
||||
app.mount(mountingTarget);
|
||||
return app;
|
||||
}
|
40
packages/@n8n/chat/src/main.scss
Normal file
40
packages/@n8n/chat/src/main.scss
Normal file
|
@ -0,0 +1,40 @@
|
|||
.n8n-chat {
|
||||
@import 'highlight.js/styles/github';
|
||||
}
|
||||
|
||||
:root {
|
||||
--chat--color-primary: #e74266;
|
||||
--chat--color-primary-shade-50: #db4061;
|
||||
--chat--color-primary-shade-100: #cf3c5c;
|
||||
--chat--color-secondary: #20b69e;
|
||||
--chat--color-secondary-shade-50: #1ca08a;
|
||||
--chat--color-white: #ffffff;
|
||||
--chat--color-light: #f2f4f8;
|
||||
--chat--color-light-shade-50: #e6e9f1;
|
||||
--chat--color-light-shade-100: #c2c5cc;
|
||||
--chat--color-medium: #d2d4d9;
|
||||
--chat--color-dark: #101330;
|
||||
--chat--color-disabled: #777980;
|
||||
--chat--color-typing: #404040;
|
||||
|
||||
--chat--spacing: 1rem;
|
||||
--chat--border-radius: 0.25rem;
|
||||
--chat--transition-duration: 0.15s;
|
||||
|
||||
--chat--window--width: 400px;
|
||||
--chat--window--height: 600px;
|
||||
|
||||
--chat--textarea--height: 50px;
|
||||
|
||||
--chat--message--bot--background: var(--chat--color-white);
|
||||
--chat--message--bot--color: var(--chat--color-dark);
|
||||
--chat--message--user--background: var(--chat--color-secondary);
|
||||
--chat--message--user--color: var(--chat--color-white);
|
||||
--chat--message--pre--background: rgba(0, 0, 0, 0.05);
|
||||
|
||||
--chat--toggle--background: var(--chat--color-primary);
|
||||
--chat--toggle--hover--background: var(--chat--color-primary-shade-50);
|
||||
--chat--toggle--active--background: var(--chat--color-primary-shade-100);
|
||||
--chat--toggle--color: var(--chat--color-white);
|
||||
--chat--toggle--size: 64px;
|
||||
}
|
101
packages/@n8n/chat/src/plugins/chat.ts
Normal file
101
packages/@n8n/chat/src/plugins/chat.ts
Normal file
|
@ -0,0 +1,101 @@
|
|||
import type { Plugin } from 'vue';
|
||||
import { computed, nextTick, ref } from 'vue';
|
||||
import type { ChatMessage, ChatOptions } from '@/types';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { chatEventBus } from '@/event-buses';
|
||||
import * as api from '@/api';
|
||||
import { ChatOptionsSymbol, ChatSymbol, localStorageSessionIdKey } from '@/constants';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
export const ChatPlugin: Plugin<ChatOptions> = {
|
||||
install(app, options) {
|
||||
app.provide(ChatOptionsSymbol, options);
|
||||
|
||||
const messages = ref<ChatMessage[]>([]);
|
||||
const currentSessionId = ref<string | null>(null);
|
||||
const waitingForResponse = ref(false);
|
||||
|
||||
const initialMessages = computed<ChatMessage[]>(() =>
|
||||
(options.initialMessages ?? []).map((text) => ({
|
||||
id: uuidv4(),
|
||||
text,
|
||||
sender: 'bot',
|
||||
createdAt: new Date().toISOString(),
|
||||
})),
|
||||
);
|
||||
|
||||
async function sendMessage(text: string) {
|
||||
const sentMessage: ChatMessage = {
|
||||
id: uuidv4(),
|
||||
text,
|
||||
sender: 'user',
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
messages.value.push(sentMessage);
|
||||
waitingForResponse.value = true;
|
||||
|
||||
void nextTick(() => {
|
||||
chatEventBus.emit('scrollToBottom');
|
||||
});
|
||||
|
||||
const sendMessageResponse = await api.sendMessage(
|
||||
text,
|
||||
currentSessionId.value as string,
|
||||
options,
|
||||
);
|
||||
|
||||
const receivedMessage: ChatMessage = {
|
||||
id: uuidv4(),
|
||||
text: sendMessageResponse.output,
|
||||
sender: 'bot',
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
messages.value.push(receivedMessage);
|
||||
|
||||
waitingForResponse.value = false;
|
||||
|
||||
void nextTick(() => {
|
||||
chatEventBus.emit('scrollToBottom');
|
||||
});
|
||||
}
|
||||
|
||||
async function loadPreviousSession() {
|
||||
const sessionId = localStorage.getItem(localStorageSessionIdKey) ?? uuidv4();
|
||||
const previousMessagesResponse = await api.loadPreviousSession(sessionId, options);
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
messages.value = (previousMessagesResponse?.data || []).map((message, index) => ({
|
||||
id: `${index}`,
|
||||
text: message.kwargs.content,
|
||||
sender: message.id.includes('HumanMessage') ? 'user' : 'bot',
|
||||
createdAt: timestamp,
|
||||
}));
|
||||
|
||||
if (messages.value.length) {
|
||||
currentSessionId.value = sessionId;
|
||||
}
|
||||
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
async function startNewSession() {
|
||||
currentSessionId.value = uuidv4();
|
||||
|
||||
localStorage.setItem(localStorageSessionIdKey, currentSessionId.value);
|
||||
}
|
||||
|
||||
const chatStore = {
|
||||
initialMessages,
|
||||
messages,
|
||||
currentSessionId,
|
||||
waitingForResponse,
|
||||
loadPreviousSession,
|
||||
startNewSession,
|
||||
sendMessage,
|
||||
};
|
||||
|
||||
app.provide(ChatSymbol, chatStore);
|
||||
app.config.globalProperties.$chat = chatStore;
|
||||
},
|
||||
};
|
1
packages/@n8n/chat/src/plugins/index.ts
Normal file
1
packages/@n8n/chat/src/plugins/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export * from './chat';
|
6
packages/@n8n/chat/src/shims.d.ts
vendored
Normal file
6
packages/@n8n/chat/src/shims.d.ts
vendored
Normal file
|
@ -0,0 +1,6 @@
|
|||
declare module '*.vue' {
|
||||
import { defineComponent } from 'vue';
|
||||
|
||||
const component: ReturnType<typeof defineComponent>;
|
||||
export default component;
|
||||
}
|
12
packages/@n8n/chat/src/types/chat.ts
Normal file
12
packages/@n8n/chat/src/types/chat.ts
Normal file
|
@ -0,0 +1,12 @@
|
|||
import type { ChatMessage } from '@/types/messages';
|
||||
import type { Ref } from 'vue';
|
||||
|
||||
export interface Chat {
|
||||
initialMessages: Ref<ChatMessage[]>;
|
||||
messages: Ref<ChatMessage[]>;
|
||||
currentSessionId: Ref<string | null>;
|
||||
waitingForResponse: Ref<boolean>;
|
||||
loadPreviousSession: () => Promise<string>;
|
||||
startNewSession: () => Promise<void>;
|
||||
sendMessage: (text: string) => Promise<void>;
|
||||
}
|
4
packages/@n8n/chat/src/types/index.ts
Normal file
4
packages/@n8n/chat/src/types/index.ts
Normal file
|
@ -0,0 +1,4 @@
|
|||
export * from './chat';
|
||||
export * from './messages';
|
||||
export * from './options';
|
||||
export * from './webhook';
|
6
packages/@n8n/chat/src/types/messages.ts
Normal file
6
packages/@n8n/chat/src/types/messages.ts
Normal file
|
@ -0,0 +1,6 @@
|
|||
export interface ChatMessage {
|
||||
id: string;
|
||||
text: string;
|
||||
createdAt: string;
|
||||
sender: 'user' | 'bot';
|
||||
}
|
23
packages/@n8n/chat/src/types/options.ts
Normal file
23
packages/@n8n/chat/src/types/options.ts
Normal file
|
@ -0,0 +1,23 @@
|
|||
export interface ChatOptions {
|
||||
webhookUrl: string;
|
||||
webhookConfig?: {
|
||||
method?: 'GET' | 'POST';
|
||||
headers?: Record<string, string>;
|
||||
};
|
||||
target?: string | Element;
|
||||
mode?: 'window' | 'fullscreen';
|
||||
defaultLanguage?: 'en';
|
||||
initialMessages?: string[];
|
||||
i18n: Record<
|
||||
string,
|
||||
{
|
||||
title: string;
|
||||
subtitle: string;
|
||||
footer: string;
|
||||
getStarted: string;
|
||||
inputPlaceholder: string;
|
||||
[message: string]: string;
|
||||
}
|
||||
>;
|
||||
theme?: {};
|
||||
}
|
17
packages/@n8n/chat/src/types/webhook.ts
Normal file
17
packages/@n8n/chat/src/types/webhook.ts
Normal file
|
@ -0,0 +1,17 @@
|
|||
export interface LoadPreviousSessionResponseItem {
|
||||
id: string[];
|
||||
kwargs: {
|
||||
content: string;
|
||||
additional_kwargs: Record<string, unknown>;
|
||||
};
|
||||
lc: number;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface LoadPreviousSessionResponse {
|
||||
data: LoadPreviousSessionResponseItem[];
|
||||
}
|
||||
|
||||
export interface SendMessageResponse {
|
||||
output: string;
|
||||
}
|
51
packages/@n8n/chat/src/utils/event-bus.ts
Normal file
51
packages/@n8n/chat/src/utils/event-bus.ts
Normal file
|
@ -0,0 +1,51 @@
|
|||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
export type CallbackFn = Function;
|
||||
export type UnregisterFn = () => void;
|
||||
|
||||
export interface EventBus {
|
||||
on: (eventName: string, fn: CallbackFn) => UnregisterFn;
|
||||
off: (eventName: string, fn: CallbackFn) => void;
|
||||
emit: <T = Event>(eventName: string, event?: T) => void;
|
||||
}
|
||||
|
||||
export function createEventBus(): EventBus {
|
||||
const handlers = new Map<string, CallbackFn[]>();
|
||||
|
||||
function off(eventName: string, fn: CallbackFn) {
|
||||
const eventFns = handlers.get(eventName);
|
||||
|
||||
if (eventFns) {
|
||||
eventFns.splice(eventFns.indexOf(fn) >>> 0, 1);
|
||||
}
|
||||
}
|
||||
|
||||
function on(eventName: string, fn: CallbackFn): UnregisterFn {
|
||||
let eventFns = handlers.get(eventName);
|
||||
|
||||
if (!eventFns) {
|
||||
eventFns = [fn];
|
||||
} else {
|
||||
eventFns.push(fn);
|
||||
}
|
||||
|
||||
handlers.set(eventName, eventFns);
|
||||
|
||||
return () => off(eventName, fn);
|
||||
}
|
||||
|
||||
function emit<T = Event>(eventName: string, event?: T) {
|
||||
const eventFns = handlers.get(eventName);
|
||||
|
||||
if (eventFns) {
|
||||
eventFns.slice().forEach(async (handler) => {
|
||||
await handler(event);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
on,
|
||||
off,
|
||||
emit,
|
||||
};
|
||||
}
|
2
packages/@n8n/chat/src/utils/index.ts
Normal file
2
packages/@n8n/chat/src/utils/index.ts
Normal file
|
@ -0,0 +1,2 @@
|
|||
export * from './event-bus';
|
||||
export * from './mount';
|
16
packages/@n8n/chat/src/utils/mount.ts
Normal file
16
packages/@n8n/chat/src/utils/mount.ts
Normal file
|
@ -0,0 +1,16 @@
|
|||
export function createDefaultMountingTarget(mountingTarget: string) {
|
||||
const mountingTargetNode = document.querySelector(mountingTarget);
|
||||
if (!mountingTargetNode) {
|
||||
const generatedMountingTargetNode = document.createElement('div');
|
||||
|
||||
if (mountingTarget.startsWith('#')) {
|
||||
generatedMountingTargetNode.id = mountingTarget.replace('#', '');
|
||||
}
|
||||
|
||||
if (mountingTarget.startsWith('.')) {
|
||||
generatedMountingTargetNode.classList.add(mountingTarget.replace('.', ''));
|
||||
}
|
||||
|
||||
document.body.appendChild(generatedMountingTargetNode);
|
||||
}
|
||||
}
|
27
packages/@n8n/chat/tsconfig.json
Normal file
27
packages/@n8n/chat/tsconfig.json
Normal file
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"extends": "../../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": ".",
|
||||
"outDir": "dist",
|
||||
"target": "esnext",
|
||||
"module": "esnext",
|
||||
"allowJs": true,
|
||||
"importHelpers": true,
|
||||
"incremental": false,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"resolveJsonModule": true,
|
||||
"baseUrl": ".",
|
||||
"types": ["vitest/globals", "unplugin-icons/types/vue"],
|
||||
"paths": {
|
||||
"@/*": ["src/*"],
|
||||
"n8n-design-system/*": ["../design-system/src/*"]
|
||||
},
|
||||
"lib": ["esnext", "dom", "dom.iterable", "scripthost"],
|
||||
// TODO: remove all options below this line
|
||||
"noUnusedLocals": false,
|
||||
"useUnknownInCatchVariables": false
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.vue", "**/*.d.ts"]
|
||||
}
|
||||
|
||||
|
51
packages/@n8n/chat/vite.config.ts
Normal file
51
packages/@n8n/chat/vite.config.ts
Normal file
|
@ -0,0 +1,51 @@
|
|||
import { fileURLToPath, URL } from 'node:url';
|
||||
|
||||
import { defineConfig } from 'vite';
|
||||
import { resolve } from 'path';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
import icons from 'unplugin-icons/vite';
|
||||
import dts from 'vite-plugin-dts';
|
||||
|
||||
const includeVue = process.env.INCLUDE_VUE === 'true';
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
icons({
|
||||
compiler: 'vue3',
|
||||
}),
|
||||
dts(),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
define: {
|
||||
'process.env.NODE_ENV': process.env.NODE_ENV ? `"${process.env.NODE_ENV}"` : '"development"',
|
||||
},
|
||||
build: {
|
||||
emptyOutDir: !includeVue,
|
||||
lib: {
|
||||
entry: resolve(__dirname, 'src', 'index.ts'),
|
||||
name: 'N8nChat',
|
||||
fileName: (format) => (includeVue ? `chat.bundle.${format}.js` : `chat.${format}.js`),
|
||||
},
|
||||
rollupOptions: {
|
||||
// make sure to externalize deps that shouldn't be bundled
|
||||
// into your library
|
||||
external: includeVue ? [] : ['vue'],
|
||||
output: {
|
||||
exports: 'named',
|
||||
// Provide global variables to use in the UMD build
|
||||
// for externalized deps
|
||||
globals: includeVue
|
||||
? {}
|
||||
: {
|
||||
vue: 'Vue',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
20
packages/@n8n/chat/vitest.config.ts
Normal file
20
packages/@n8n/chat/vitest.config.ts
Normal file
|
@ -0,0 +1,20 @@
|
|||
import { fileURLToPath } from 'node:url';
|
||||
import { mergeConfig, defineConfig } from 'vite';
|
||||
import { configDefaults } from 'vitest/config';
|
||||
import viteConfig from './vite.config';
|
||||
|
||||
export default mergeConfig(
|
||||
viteConfig,
|
||||
defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
exclude: [...configDefaults.exclude, 'e2e/*'],
|
||||
root: fileURLToPath(new URL('./', import.meta.url)),
|
||||
setupFiles: ['./src/__tests__/setup.ts'],
|
||||
transformMode: {
|
||||
web: [/\.[jt]sx$/],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
20
packages/@n8n/nodes-langchain/.editorconfig
Normal file
20
packages/@n8n/nodes-langchain/.editorconfig
Normal file
|
@ -0,0 +1,20 @@
|
|||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
indent_style = tab
|
||||
indent_size = 2
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[package.json]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
[*.yml]
|
||||
indent_style = space
|
||||
indent_size = 2
|
155
packages/@n8n/nodes-langchain/.eslintrc.js
Normal file
155
packages/@n8n/nodes-langchain/.eslintrc.js
Normal file
|
@ -0,0 +1,155 @@
|
|||
const sharedOptions = require('@n8n_io/eslint-config/shared');
|
||||
|
||||
/**
|
||||
* @type {import('@types/eslint').ESLint.ConfigData}
|
||||
*/
|
||||
module.exports = {
|
||||
extends: ['@n8n_io/eslint-config/node'],
|
||||
|
||||
...sharedOptions(__dirname),
|
||||
|
||||
ignorePatterns: ['index.js', '**/package.json'],
|
||||
|
||||
rules: {
|
||||
// TODO: remove all the following rules
|
||||
eqeqeq: 'warn',
|
||||
'id-denylist': 'warn',
|
||||
'import/extensions': 'warn',
|
||||
'import/order': 'warn',
|
||||
'prefer-spread': 'warn',
|
||||
'import/no-extraneous-dependencies': 'warn',
|
||||
|
||||
'@typescript-eslint/naming-convention': ['error', { selector: 'memberLike', format: null }],
|
||||
'@typescript-eslint/no-explicit-any': 'warn', //812 warnings, better to fix in separate PR
|
||||
'@typescript-eslint/no-non-null-assertion': 'warn', //665 errors, better to fix in separate PR
|
||||
'@typescript-eslint/no-unsafe-assignment': 'warn', //7084 problems, better to fix in separate PR
|
||||
'@typescript-eslint/no-unsafe-call': 'warn', //541 errors, better to fix in separate PR
|
||||
'@typescript-eslint/no-unsafe-member-access': 'warn', //4591 errors, better to fix in separate PR
|
||||
'@typescript-eslint/no-unsafe-return': 'warn', //438 errors, better to fix in separate PR
|
||||
'@typescript-eslint/no-unused-expressions': ['error', { allowTernary: true }],
|
||||
'@typescript-eslint/restrict-template-expressions': 'warn', //1152 errors, better to fix in separate PR
|
||||
'@typescript-eslint/unbound-method': 'warn',
|
||||
'@typescript-eslint/ban-ts-comment': ['warn', { 'ts-ignore': true }],
|
||||
'@typescript-eslint/prefer-nullish-coalescing': 'warn',
|
||||
'@typescript-eslint/no-base-to-string': 'warn',
|
||||
'@typescript-eslint/no-redundant-type-constituents': 'warn',
|
||||
'@typescript-eslint/no-unused-vars': 'warn',
|
||||
'@typescript-eslint/no-unsafe-argument': 'warn',
|
||||
'@typescript-eslint/prefer-optional-chain': 'warn',
|
||||
'@typescript-eslint/restrict-plus-operands': 'warn',
|
||||
},
|
||||
|
||||
overrides: [
|
||||
{
|
||||
files: ['./credentials/*.ts'],
|
||||
plugins: ['eslint-plugin-n8n-nodes-base'],
|
||||
rules: {
|
||||
'n8n-nodes-base/cred-class-field-authenticate-type-assertion': 'error',
|
||||
'n8n-nodes-base/cred-class-field-display-name-missing-oauth2': 'error',
|
||||
'n8n-nodes-base/cred-class-field-display-name-miscased': 'error',
|
||||
'n8n-nodes-base/cred-class-field-documentation-url-missing': 'error',
|
||||
'n8n-nodes-base/cred-class-field-name-missing-oauth2': 'error',
|
||||
'n8n-nodes-base/cred-class-field-name-unsuffixed': 'error',
|
||||
'n8n-nodes-base/cred-class-field-name-uppercase-first-char': 'error',
|
||||
'n8n-nodes-base/cred-class-field-properties-assertion': 'error',
|
||||
'n8n-nodes-base/cred-class-field-type-options-password-missing': 'error',
|
||||
'n8n-nodes-base/cred-class-name-missing-oauth2-suffix': 'error',
|
||||
'n8n-nodes-base/cred-class-name-unsuffixed': 'error',
|
||||
'n8n-nodes-base/cred-filename-against-convention': 'error',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['./nodes/**/*.ts'],
|
||||
plugins: ['eslint-plugin-n8n-nodes-base'],
|
||||
rules: {
|
||||
'n8n-nodes-base/node-class-description-credentials-name-unsuffixed': 'error',
|
||||
'n8n-nodes-base/node-class-description-display-name-unsuffixed-trigger-node': 'error',
|
||||
'n8n-nodes-base/node-class-description-empty-string': 'error',
|
||||
'n8n-nodes-base/node-class-description-icon-not-svg': 'error',
|
||||
'n8n-nodes-base/node-class-description-inputs-wrong-regular-node': 'off',
|
||||
'n8n-nodes-base/node-class-description-outputs-wrong': 'off',
|
||||
'n8n-nodes-base/node-class-description-inputs-wrong-trigger-node': 'error',
|
||||
'n8n-nodes-base/node-class-description-missing-subtitle': 'error',
|
||||
'n8n-nodes-base/node-class-description-non-core-color-present': 'error',
|
||||
'n8n-nodes-base/node-class-description-name-miscased': 'error',
|
||||
'n8n-nodes-base/node-class-description-name-unsuffixed-trigger-node': 'error',
|
||||
'n8n-nodes-base/node-dirname-against-convention': 'error',
|
||||
'n8n-nodes-base/node-execute-block-double-assertion-for-items': 'error',
|
||||
'n8n-nodes-base/node-execute-block-wrong-error-thrown': 'error',
|
||||
'n8n-nodes-base/node-filename-against-convention': 'error',
|
||||
'n8n-nodes-base/node-param-array-type-assertion': 'error',
|
||||
'n8n-nodes-base/node-param-collection-type-unsorted-items': 'error',
|
||||
'n8n-nodes-base/node-param-color-type-unused': 'error',
|
||||
'n8n-nodes-base/node-param-default-missing': 'error',
|
||||
'n8n-nodes-base/node-param-default-wrong-for-boolean': 'error',
|
||||
'n8n-nodes-base/node-param-default-wrong-for-collection': 'error',
|
||||
'n8n-nodes-base/node-param-default-wrong-for-fixed-collection': 'error',
|
||||
'n8n-nodes-base/node-param-default-wrong-for-fixed-collection': 'error',
|
||||
'n8n-nodes-base/node-param-default-wrong-for-multi-options': 'error',
|
||||
'n8n-nodes-base/node-param-default-wrong-for-number': 'error',
|
||||
'n8n-nodes-base/node-param-default-wrong-for-simplify': 'error',
|
||||
'n8n-nodes-base/node-param-default-wrong-for-string': 'error',
|
||||
'n8n-nodes-base/node-param-description-boolean-without-whether': 'error',
|
||||
'n8n-nodes-base/node-param-description-comma-separated-hyphen': 'error',
|
||||
'n8n-nodes-base/node-param-description-empty-string': 'error',
|
||||
'n8n-nodes-base/node-param-description-excess-final-period': 'error',
|
||||
'n8n-nodes-base/node-param-description-excess-inner-whitespace': 'error',
|
||||
'n8n-nodes-base/node-param-description-identical-to-display-name': 'error',
|
||||
'n8n-nodes-base/node-param-description-line-break-html-tag': 'error',
|
||||
'n8n-nodes-base/node-param-description-lowercase-first-char': 'error',
|
||||
'n8n-nodes-base/node-param-description-miscased-id': 'error',
|
||||
'n8n-nodes-base/node-param-description-miscased-json': 'error',
|
||||
'n8n-nodes-base/node-param-description-miscased-url': 'error',
|
||||
'n8n-nodes-base/node-param-description-missing-final-period': 'error',
|
||||
'n8n-nodes-base/node-param-description-missing-for-ignore-ssl-issues': 'error',
|
||||
'n8n-nodes-base/node-param-description-missing-for-return-all': 'error',
|
||||
'n8n-nodes-base/node-param-description-missing-for-simplify': 'error',
|
||||
'n8n-nodes-base/node-param-description-missing-from-dynamic-multi-options': 'error',
|
||||
'n8n-nodes-base/node-param-description-missing-from-dynamic-options': 'error',
|
||||
'n8n-nodes-base/node-param-description-missing-from-limit': 'error',
|
||||
'n8n-nodes-base/node-param-description-unencoded-angle-brackets': 'error',
|
||||
'n8n-nodes-base/node-param-description-unneeded-backticks': 'error',
|
||||
'n8n-nodes-base/node-param-description-untrimmed': 'error',
|
||||
'n8n-nodes-base/node-param-description-url-missing-protocol': 'error',
|
||||
'n8n-nodes-base/node-param-description-weak': 'error',
|
||||
'n8n-nodes-base/node-param-description-wrong-for-dynamic-multi-options': 'error',
|
||||
'n8n-nodes-base/node-param-description-wrong-for-dynamic-options': 'error',
|
||||
'n8n-nodes-base/node-param-description-wrong-for-ignore-ssl-issues': 'error',
|
||||
'n8n-nodes-base/node-param-description-wrong-for-limit': 'error',
|
||||
'n8n-nodes-base/node-param-description-wrong-for-return-all': 'error',
|
||||
'n8n-nodes-base/node-param-description-wrong-for-simplify': 'error',
|
||||
'n8n-nodes-base/node-param-description-wrong-for-upsert': 'error',
|
||||
'n8n-nodes-base/node-param-display-name-excess-inner-whitespace': 'error',
|
||||
'n8n-nodes-base/node-param-display-name-miscased-id': 'error',
|
||||
'n8n-nodes-base/node-param-display-name-miscased': 'error',
|
||||
'n8n-nodes-base/node-param-display-name-not-first-position': 'error',
|
||||
'n8n-nodes-base/node-param-display-name-untrimmed': 'error',
|
||||
'n8n-nodes-base/node-param-display-name-wrong-for-dynamic-multi-options': 'error',
|
||||
'n8n-nodes-base/node-param-display-name-wrong-for-dynamic-options': 'error',
|
||||
'n8n-nodes-base/node-param-display-name-wrong-for-simplify': 'error',
|
||||
'n8n-nodes-base/node-param-display-name-wrong-for-update-fields': 'error',
|
||||
'n8n-nodes-base/node-param-min-value-wrong-for-limit': 'error',
|
||||
'n8n-nodes-base/node-param-multi-options-type-unsorted-items': 'error',
|
||||
'n8n-nodes-base/node-param-name-untrimmed': 'error',
|
||||
'n8n-nodes-base/node-param-operation-option-action-wrong-for-get-many': 'error',
|
||||
'n8n-nodes-base/node-param-operation-option-description-wrong-for-get-many': 'error',
|
||||
'n8n-nodes-base/node-param-operation-option-without-action': 'error',
|
||||
'n8n-nodes-base/node-param-operation-without-no-data-expression': 'error',
|
||||
'n8n-nodes-base/node-param-option-description-identical-to-name': 'error',
|
||||
'n8n-nodes-base/node-param-option-name-containing-star': 'error',
|
||||
'n8n-nodes-base/node-param-option-name-duplicate': 'error',
|
||||
'n8n-nodes-base/node-param-option-name-wrong-for-get-many': 'error',
|
||||
'n8n-nodes-base/node-param-option-name-wrong-for-upsert': 'error',
|
||||
'n8n-nodes-base/node-param-option-value-duplicate': 'error',
|
||||
'n8n-nodes-base/node-param-options-type-unsorted-items': 'error',
|
||||
'n8n-nodes-base/node-param-placeholder-miscased-id': 'error',
|
||||
'n8n-nodes-base/node-param-placeholder-missing-email': 'error',
|
||||
'n8n-nodes-base/node-param-required-false': 'error',
|
||||
'n8n-nodes-base/node-param-resource-with-plural-option': 'error',
|
||||
'n8n-nodes-base/node-param-resource-without-no-data-expression': 'error',
|
||||
'n8n-nodes-base/node-param-type-options-missing-from-limit': 'error',
|
||||
'n8n-nodes-base/node-param-type-options-password-missing': 'error',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
8
packages/@n8n/nodes-langchain/.gitignore
vendored
Normal file
8
packages/@n8n/nodes-langchain/.gitignore
vendored
Normal file
|
@ -0,0 +1,8 @@
|
|||
node_modules
|
||||
.DS_Store
|
||||
.tmp
|
||||
tmp
|
||||
dist
|
||||
npm-debug.log*
|
||||
yarn.lock
|
||||
.vscode/launch.json
|
2
packages/@n8n/nodes-langchain/.npmignore
Normal file
2
packages/@n8n/nodes-langchain/.npmignore
Normal file
|
@ -0,0 +1,2 @@
|
|||
.DS_Store
|
||||
*.tsbuildinfo
|
51
packages/@n8n/nodes-langchain/.prettierrc.js
Normal file
51
packages/@n8n/nodes-langchain/.prettierrc.js
Normal file
|
@ -0,0 +1,51 @@
|
|||
module.exports = {
|
||||
/**
|
||||
* https://prettier.io/docs/en/options.html#semicolons
|
||||
*/
|
||||
semi: true,
|
||||
|
||||
/**
|
||||
* https://prettier.io/docs/en/options.html#trailing-commas
|
||||
*/
|
||||
trailingComma: 'all',
|
||||
|
||||
/**
|
||||
* https://prettier.io/docs/en/options.html#bracket-spacing
|
||||
*/
|
||||
bracketSpacing: true,
|
||||
|
||||
/**
|
||||
* https://prettier.io/docs/en/options.html#tabs
|
||||
*/
|
||||
useTabs: true,
|
||||
|
||||
/**
|
||||
* https://prettier.io/docs/en/options.html#tab-width
|
||||
*/
|
||||
tabWidth: 2,
|
||||
|
||||
/**
|
||||
* https://prettier.io/docs/en/options.html#arrow-function-parentheses
|
||||
*/
|
||||
arrowParens: 'always',
|
||||
|
||||
/**
|
||||
* https://prettier.io/docs/en/options.html#quotes
|
||||
*/
|
||||
singleQuote: true,
|
||||
|
||||
/**
|
||||
* https://prettier.io/docs/en/options.html#quote-props
|
||||
*/
|
||||
quoteProps: 'as-needed',
|
||||
|
||||
/**
|
||||
* https://prettier.io/docs/en/options.html#end-of-line
|
||||
*/
|
||||
endOfLine: 'lf',
|
||||
|
||||
/**
|
||||
* https://prettier.io/docs/en/options.html#print-width
|
||||
*/
|
||||
printWidth: 100,
|
||||
};
|
7
packages/@n8n/nodes-langchain/.vscode/extensions.json
vendored
Normal file
7
packages/@n8n/nodes-langchain/.vscode/extensions.json
vendored
Normal file
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"recommendations": [
|
||||
"dbaeumer.vscode-eslint",
|
||||
"EditorConfig.EditorConfig",
|
||||
"esbenp.prettier-vscode",
|
||||
]
|
||||
}
|
85
packages/@n8n/nodes-langchain/LICENSE.md
Normal file
85
packages/@n8n/nodes-langchain/LICENSE.md
Normal file
|
@ -0,0 +1,85 @@
|
|||
# License
|
||||
|
||||
Portions of this software are licensed as follows:
|
||||
|
||||
- Content of branches other than the main branch (i.e. "master") are not licensed.
|
||||
- All source code files that contain ".ee." in their filename are licensed under the
|
||||
"n8n Enterprise License" defined in "LICENSE_EE.md".
|
||||
- All third party components incorporated into the n8n Software are licensed under the original license
|
||||
provided by the owner of the applicable component.
|
||||
- Content outside of the above mentioned files or restrictions is available under the "Sustainable Use
|
||||
License" as defined below.
|
||||
|
||||
## Sustainable Use License
|
||||
|
||||
Version 1.0
|
||||
|
||||
### Acceptance
|
||||
|
||||
By using the software, you agree to all of the terms and conditions below.
|
||||
|
||||
### Copyright License
|
||||
|
||||
The licensor grants you a non-exclusive, royalty-free, worldwide, non-sublicensable, non-transferable license
|
||||
to use, copy, distribute, make available, and prepare derivative works of the software, in each case subject
|
||||
to the limitations below.
|
||||
|
||||
### Limitations
|
||||
|
||||
You may use or modify the software only for your own internal business purposes or for non-commercial or
|
||||
personal use. You may distribute the software or provide it to others only if you do so free of charge for
|
||||
non-commercial purposes. You may not alter, remove, or obscure any licensing, copyright, or other notices of
|
||||
the licensor in the software. Any use of the licensor’s trademarks is subject to applicable law.
|
||||
|
||||
### Patents
|
||||
|
||||
The licensor grants you a license, under any patent claims the licensor can license, or becomes able to
|
||||
license, to make, have made, use, sell, offer for sale, import and have imported the software, in each case
|
||||
subject to the limitations and conditions in this license. This license does not cover any patent claims that
|
||||
you cause to be infringed by modifications or additions to the software. If you or your company make any
|
||||
written claim that the software infringes or contributes to infringement of any patent, your patent license
|
||||
for the software granted under these terms ends immediately. If your company makes such a claim, your patent
|
||||
license ends immediately for work on behalf of your company.
|
||||
|
||||
### Notices
|
||||
|
||||
You must ensure that anyone who gets a copy of any part of the software from you also gets a copy of these
|
||||
terms. If you modify the software, you must include in any modified copies of the software a prominent notice
|
||||
stating that you have modified the software.
|
||||
|
||||
### No Other Rights
|
||||
|
||||
These terms do not imply any licenses other than those expressly granted in these terms.
|
||||
|
||||
### Termination
|
||||
|
||||
If you use the software in violation of these terms, such use is not licensed, and your license will
|
||||
automatically terminate. If the licensor provides you with a notice of your violation, and you cease all
|
||||
violation of this license no later than 30 days after you receive that notice, your license will be reinstated
|
||||
retroactively. However, if you violate these terms after such reinstatement, any additional violation of these
|
||||
terms will cause your license to terminate automatically and permanently.
|
||||
|
||||
### No Liability
|
||||
|
||||
As far as the law allows, the software comes as is, without any warranty or condition, and the licensor will
|
||||
not be liable to you for any damages arising out of these terms or the use or nature of the software, under
|
||||
any kind of legal claim.
|
||||
|
||||
### Definitions
|
||||
|
||||
The “licensor” is the entity offering these terms.
|
||||
|
||||
The “software” is the software the licensor makes available under these terms, including any portion of it.
|
||||
|
||||
“You” refers to the individual or entity agreeing to these terms.
|
||||
|
||||
“Your company” is any legal entity, sole proprietorship, or other kind of organization that you work for, plus
|
||||
all organizations that have control over, are under the control of, or are under common control with that
|
||||
organization. Control means ownership of substantially all the assets of an entity, or the power to direct its
|
||||
management and policies by vote, contract, or otherwise. Control can be direct or indirect.
|
||||
|
||||
“Your license” is the license granted to you for the software under these terms.
|
||||
|
||||
“Use” means anything you do with the software requiring your license.
|
||||
|
||||
“Trademark” means trademarks, service marks, and similar rights.
|
13
packages/@n8n/nodes-langchain/README.md
Normal file
13
packages/@n8n/nodes-langchain/README.md
Normal file
|
@ -0,0 +1,13 @@
|
|||
![Banner image](https://user-images.githubusercontent.com/10284570/173569848-c624317f-42b1-45a6-ab09-f0ea3c247648.png)
|
||||
|
||||
# n8n-nodes-langchain
|
||||
|
||||
This repo contains nodes to use n8n in combination with [LangChain](https://langchain.com/).
|
||||
|
||||
These nodes are still in Beta state and are only compatible with the Docker image `docker.n8n.io/n8nio/n8n:ai-beta`.
|
||||
|
||||
## License
|
||||
|
||||
n8n is [fair-code](http://faircode.io) distributed under the [**Sustainable Use License**](https://github.com/n8n-io/n8n/blob/master/packages/cli/LICENSE.md).
|
||||
|
||||
Additional information about the license can be found in the [docs](https://docs.n8n.io/reference/license/).
|
|
@ -0,0 +1,50 @@
|
|||
import type {
|
||||
IAuthenticateGeneric,
|
||||
ICredentialTestRequest,
|
||||
ICredentialType,
|
||||
INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
export class AnthropicApi implements ICredentialType {
|
||||
name = 'anthropicApi';
|
||||
|
||||
displayName = 'Anthropic';
|
||||
|
||||
documentationUrl = 'anthropic';
|
||||
|
||||
properties: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'API Key',
|
||||
name: 'apiKey',
|
||||
type: 'string',
|
||||
typeOptions: { password: true },
|
||||
required: true,
|
||||
default: '',
|
||||
},
|
||||
];
|
||||
|
||||
authenticate: IAuthenticateGeneric = {
|
||||
type: 'generic',
|
||||
properties: {
|
||||
headers: {
|
||||
'x-api-key': '={{$credentials.apiKey}}',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
test: ICredentialTestRequest = {
|
||||
request: {
|
||||
baseURL: 'https://api.anthropic.com',
|
||||
url: '/v1/complete',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'anthropic-version': '2023-06-01',
|
||||
},
|
||||
body: {
|
||||
model: 'claude-2',
|
||||
prompt: '\n\nHuman: Hello, world!\n\nAssistant:',
|
||||
max_tokens_to_sample: 256,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
|
@ -0,0 +1,45 @@
|
|||
import type {
|
||||
IAuthenticateGeneric,
|
||||
ICredentialTestRequest,
|
||||
ICredentialType,
|
||||
INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
export class CohereApi implements ICredentialType {
|
||||
name = 'cohereApi';
|
||||
|
||||
displayName = 'CohereApi';
|
||||
|
||||
documentationUrl = 'cohere';
|
||||
|
||||
properties: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'API Key',
|
||||
name: 'apiKey',
|
||||
type: 'string',
|
||||
typeOptions: { password: true },
|
||||
required: true,
|
||||
default: '',
|
||||
},
|
||||
];
|
||||
|
||||
authenticate: IAuthenticateGeneric = {
|
||||
type: 'generic',
|
||||
properties: {
|
||||
headers: {
|
||||
Authorization: '=Bearer {{$credentials.apiKey}}',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
test: ICredentialTestRequest = {
|
||||
request: {
|
||||
baseURL: 'https://api.cohere.ai',
|
||||
url: '/v1/detect-language',
|
||||
method: 'POST',
|
||||
body: {
|
||||
texts: ['hello'],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
|
@ -0,0 +1,47 @@
|
|||
import type {
|
||||
IAuthenticateGeneric,
|
||||
ICredentialTestRequest,
|
||||
ICredentialType,
|
||||
INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
export class GooglePalmApi implements ICredentialType {
|
||||
name = 'googlePalmApi';
|
||||
|
||||
displayName = 'GooglePaLMApi';
|
||||
|
||||
documentationUrl = 'googlePalm';
|
||||
|
||||
properties: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Host',
|
||||
name: 'host',
|
||||
required: true,
|
||||
type: 'string',
|
||||
default: 'https://generativelanguage.googleapis.com',
|
||||
},
|
||||
{
|
||||
displayName: 'API Key',
|
||||
name: 'apiKey',
|
||||
type: 'string',
|
||||
typeOptions: { password: true },
|
||||
required: true,
|
||||
default: '',
|
||||
},
|
||||
];
|
||||
|
||||
authenticate: IAuthenticateGeneric = {
|
||||
type: 'generic',
|
||||
properties: {
|
||||
qs: {
|
||||
key: '={{$credentials.apiKey}}',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
test: ICredentialTestRequest = {
|
||||
request: {
|
||||
baseURL: '={{$credentials.host}}/v1beta3/models',
|
||||
},
|
||||
};
|
||||
}
|
|
@ -0,0 +1,41 @@
|
|||
import type {
|
||||
IAuthenticateGeneric,
|
||||
ICredentialTestRequest,
|
||||
ICredentialType,
|
||||
INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
export class HuggingFaceApi implements ICredentialType {
|
||||
name = 'huggingFaceApi';
|
||||
|
||||
displayName = 'HuggingFaceApi';
|
||||
|
||||
documentationUrl = 'huggingface';
|
||||
|
||||
properties: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'API Key',
|
||||
name: 'apiKey',
|
||||
type: 'string',
|
||||
typeOptions: { password: true },
|
||||
required: true,
|
||||
default: '',
|
||||
},
|
||||
];
|
||||
|
||||
authenticate: IAuthenticateGeneric = {
|
||||
type: 'generic',
|
||||
properties: {
|
||||
headers: {
|
||||
Authorization: '=Bearer {{$credentials.apiKey}}',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
test: ICredentialTestRequest = {
|
||||
request: {
|
||||
baseURL: 'https://api-inference.huggingface.co',
|
||||
url: '/models/gpt2',
|
||||
},
|
||||
};
|
||||
}
|
|
@ -0,0 +1,54 @@
|
|||
import type {
|
||||
IAuthenticateGeneric,
|
||||
ICredentialTestRequest,
|
||||
ICredentialType,
|
||||
INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
export class MotorheadApi implements ICredentialType {
|
||||
name = 'motorheadApi';
|
||||
|
||||
displayName = 'MotorheadApi';
|
||||
|
||||
documentationUrl = 'motorhead';
|
||||
|
||||
properties: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Host',
|
||||
name: 'host',
|
||||
required: true,
|
||||
type: 'string',
|
||||
default: 'https://api.getmetal.io/v1',
|
||||
},
|
||||
{
|
||||
displayName: 'API Key',
|
||||
name: 'apiKey',
|
||||
type: 'string',
|
||||
typeOptions: { password: true },
|
||||
required: true,
|
||||
default: '',
|
||||
},
|
||||
{
|
||||
displayName: 'Client ID',
|
||||
name: 'clientId',
|
||||
type: 'string',
|
||||
default: '',
|
||||
},
|
||||
];
|
||||
|
||||
authenticate: IAuthenticateGeneric = {
|
||||
type: 'generic',
|
||||
properties: {
|
||||
headers: {
|
||||
'x-metal-client-id': '={{$credentials.clientId}}',
|
||||
'x-metal-api-key': '={{$credentials.apiKey}}',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
test: ICredentialTestRequest = {
|
||||
request: {
|
||||
baseURL: '={{$credentials.host}}/keys/current',
|
||||
},
|
||||
};
|
||||
}
|
|
@ -0,0 +1,34 @@
|
|||
import type { ICredentialTestRequest, ICredentialType, INodeProperties } from 'n8n-workflow';
|
||||
|
||||
export class OllamaApi implements ICredentialType {
|
||||
name = 'ollamaApi';
|
||||
|
||||
displayName = 'Ollama';
|
||||
|
||||
documentationUrl = 'ollama';
|
||||
|
||||
properties: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Base URL',
|
||||
name: 'baseUrl',
|
||||
required: true,
|
||||
type: 'string',
|
||||
default: 'http://localhost:11434',
|
||||
},
|
||||
];
|
||||
|
||||
test: ICredentialTestRequest = {
|
||||
request: {
|
||||
baseURL: '={{ $credentials.baseUrl }}',
|
||||
url: '/api/generate',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'anthropic-version': '2023-06-01',
|
||||
},
|
||||
body: {
|
||||
model: 'llama2',
|
||||
prompt: 'Hello',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
|
@ -0,0 +1,50 @@
|
|||
import type {
|
||||
IAuthenticateGeneric,
|
||||
ICredentialTestRequest,
|
||||
ICredentialType,
|
||||
INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
export class PineconeApi implements ICredentialType {
|
||||
name = 'pineconeApi';
|
||||
|
||||
displayName = 'PineconeApi';
|
||||
|
||||
documentationUrl = 'pinecone';
|
||||
|
||||
properties: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'API Key',
|
||||
name: 'apiKey',
|
||||
type: 'string',
|
||||
typeOptions: { password: true },
|
||||
required: true,
|
||||
default: '',
|
||||
},
|
||||
{
|
||||
displayName: 'Environment',
|
||||
name: 'environment',
|
||||
type: 'string',
|
||||
required: true,
|
||||
default: 'us-central1-gcp',
|
||||
},
|
||||
];
|
||||
|
||||
authenticate: IAuthenticateGeneric = {
|
||||
type: 'generic',
|
||||
properties: {
|
||||
headers: {
|
||||
'Api-Key': '={{$credentials.apiKey}}',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
test: ICredentialTestRequest = {
|
||||
request: {
|
||||
baseURL: '=https://controller.{{$credentials.environment}}.pinecone.io/databases',
|
||||
headers: {
|
||||
accept: 'application/json; charset=utf-8',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
|
@ -0,0 +1,41 @@
|
|||
import type {
|
||||
IAuthenticateGeneric,
|
||||
ICredentialTestRequest,
|
||||
ICredentialType,
|
||||
INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
export class SerpApi implements ICredentialType {
|
||||
name = 'serpApi';
|
||||
|
||||
displayName = 'SerpAPI';
|
||||
|
||||
documentationUrl = 'serpapi';
|
||||
|
||||
properties: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'API Key',
|
||||
name: 'apiKey',
|
||||
type: 'string',
|
||||
typeOptions: { password: true },
|
||||
required: true,
|
||||
default: '',
|
||||
},
|
||||
];
|
||||
|
||||
authenticate: IAuthenticateGeneric = {
|
||||
type: 'generic',
|
||||
properties: {
|
||||
qs: {
|
||||
api_key: '={{$credentials.apiKey}}',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
test: ICredentialTestRequest = {
|
||||
request: {
|
||||
baseURL: 'https://serpapi.com',
|
||||
url: '/account.json ',
|
||||
},
|
||||
};
|
||||
}
|
|
@ -0,0 +1,45 @@
|
|||
import type {
|
||||
IAuthenticateGeneric,
|
||||
ICredentialTestRequest,
|
||||
ICredentialType,
|
||||
INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
export class WolframAlphaApi implements ICredentialType {
|
||||
name = 'wolframAlphaApi';
|
||||
|
||||
displayName = 'WolframAlphaApi';
|
||||
|
||||
documentationUrl = 'wolframalpha';
|
||||
|
||||
properties: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'App ID',
|
||||
name: 'appId',
|
||||
type: 'string',
|
||||
typeOptions: { password: true },
|
||||
required: true,
|
||||
default: '',
|
||||
},
|
||||
];
|
||||
|
||||
authenticate: IAuthenticateGeneric = {
|
||||
type: 'generic',
|
||||
properties: {
|
||||
qs: {
|
||||
api_key: '={{$credentials.appId}}',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
test: ICredentialTestRequest = {
|
||||
request: {
|
||||
baseURL: 'https://api.wolframalpha.com/v1',
|
||||
url: '=/simple',
|
||||
qs: {
|
||||
i: 'How much is 1 1',
|
||||
appid: '={{$credentials.appId}}',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
|
@ -0,0 +1,55 @@
|
|||
import type {
|
||||
IAuthenticateGeneric,
|
||||
ICredentialTestRequest,
|
||||
ICredentialType,
|
||||
INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
export class XataApi implements ICredentialType {
|
||||
name = 'xataApi';
|
||||
|
||||
displayName = 'Xata Api';
|
||||
|
||||
documentationUrl = 'xata';
|
||||
|
||||
properties: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Database Endpoint',
|
||||
name: 'databaseEndpoint',
|
||||
required: true,
|
||||
type: 'string',
|
||||
default: '',
|
||||
placeholder: 'https://{workspace}.{region}.xata.sh/db/{database}',
|
||||
},
|
||||
{
|
||||
displayName: 'Branch',
|
||||
name: 'branch',
|
||||
required: true,
|
||||
type: 'string',
|
||||
default: 'main',
|
||||
},
|
||||
{
|
||||
displayName: 'API Key',
|
||||
name: 'apiKey',
|
||||
type: 'string',
|
||||
typeOptions: { password: true },
|
||||
required: true,
|
||||
default: '',
|
||||
},
|
||||
];
|
||||
|
||||
authenticate: IAuthenticateGeneric = {
|
||||
type: 'generic',
|
||||
properties: {
|
||||
headers: {
|
||||
Authorization: '=Bearer {{$credentials.apiKey}}',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
test: ICredentialTestRequest = {
|
||||
request: {
|
||||
baseURL: '={{$credentials.databaseEndpoint}}:{{$credentials.branch}}',
|
||||
},
|
||||
};
|
||||
}
|
|
@ -0,0 +1,48 @@
|
|||
import type {
|
||||
IAuthenticateGeneric,
|
||||
ICredentialTestRequest,
|
||||
ICredentialType,
|
||||
INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
export class ZepApi implements ICredentialType {
|
||||
name = 'zepApi';
|
||||
|
||||
displayName = 'Zep Api';
|
||||
|
||||
documentationUrl = 'zep';
|
||||
|
||||
properties: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'API URL',
|
||||
name: 'apiUrl',
|
||||
required: true,
|
||||
type: 'string',
|
||||
default: 'http://localhost:8000',
|
||||
},
|
||||
{
|
||||
displayName: 'API Key',
|
||||
name: 'apiKey',
|
||||
type: 'string',
|
||||
typeOptions: { password: true },
|
||||
required: false,
|
||||
default: '',
|
||||
},
|
||||
];
|
||||
|
||||
authenticate: IAuthenticateGeneric = {
|
||||
type: 'generic',
|
||||
properties: {
|
||||
headers: {
|
||||
Authorization: '={{$credentials.apiKey ? "Bearer " + $credentials.apiKey : undefined }}',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
test: ICredentialTestRequest = {
|
||||
request: {
|
||||
baseURL: '={{$credentials.apiUrl}}',
|
||||
url: '/api/v1/collection',
|
||||
},
|
||||
};
|
||||
}
|
16
packages/@n8n/nodes-langchain/gulpfile.js
Normal file
16
packages/@n8n/nodes-langchain/gulpfile.js
Normal file
|
@ -0,0 +1,16 @@
|
|||
const path = require('path');
|
||||
const { task, src, dest } = require('gulp');
|
||||
|
||||
task('build:icons', copyIcons);
|
||||
|
||||
function copyIcons() {
|
||||
const nodeSource = path.resolve('nodes', '**', '*.{png,svg}');
|
||||
const nodeDestination = path.resolve('dist', 'nodes');
|
||||
|
||||
src(nodeSource).pipe(dest(nodeDestination));
|
||||
|
||||
const credSource = path.resolve('credentials', '**', '*.{png,svg}');
|
||||
const credDestination = path.resolve('dist', 'credentials');
|
||||
|
||||
return src(credSource).pipe(dest(credDestination));
|
||||
}
|
0
packages/@n8n/nodes-langchain/index.js
Normal file
0
packages/@n8n/nodes-langchain/index.js
Normal file
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue