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:
Jan Oberhauser 2023-11-29 12:13:55 +01:00 committed by GitHub
parent dbfd617ace
commit 87def60979
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
243 changed files with 21526 additions and 321 deletions

View 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();
}

View 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();
}
}

View 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);
}

View 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();
}

View file

@ -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',
};

View 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();
});
});

View file

@ -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

View file

@ -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()}`);

View 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": []
}

View file

@ -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
View 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,
}),
);
}

View file

@ -1 +1,3 @@
export * from './executions';
export * from './modal';
export * from './popper';

View file

@ -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",

View file

@ -0,0 +1,2 @@
.eslintrc.cjs
vitest.config.ts

View 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
View 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?

View file

@ -0,0 +1,5 @@
{
"yarn": false,
"tests": false,
"contents": "./dist"
}

View 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;

View file

@ -0,0 +1,4 @@
html, body, #storybook-root, #n8n-chat {
width: 100%;
height: 100%;
}

View 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;

View file

@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar", "Vue.vscode-typescript-vue-plugin"]
}

View 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 licensors 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.

View 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
View file

@ -0,0 +1 @@
/// <reference types="vite/client" />

View 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>

View 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"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View 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": []
}

View 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');

View 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'));

View 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>

View 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>,
};

View 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!");');
},
);
});
});

View file

@ -0,0 +1 @@
import '@testing-library/jest-dom';

View 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,
};
}

View 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,
});

View file

@ -0,0 +1,3 @@
export * from './create';
export * from './fetch';
export * from './selectors';

View 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');
}

View 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),
});
}

View file

@ -0,0 +1,2 @@
export * from './generic';
export * from './message';

View 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,
},
);
}

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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';

View file

@ -0,0 +1,3 @@
export * from './useChat';
export * from './useI18n';
export * from './useOptions';

View 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;
}

View 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 };
}

View 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,
};
}

View 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';

View file

@ -0,0 +1,3 @@
export * from './defaults';
export * from './localStorage';
export * from './symbols';

View file

@ -0,0 +1,2 @@
export const localStorageNamespace = 'n8n-chat';
export const localStorageSessionIdKey = `${localStorageNamespace}/sessionId`;

View 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>;

View file

@ -0,0 +1,3 @@
import { createEventBus } from '@/utils';
export const chatEventBus = createEventBus();

View file

@ -0,0 +1 @@
export * from './chatEventBus';

View 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;
}

View 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;
}

View 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;
},
};

View file

@ -0,0 +1 @@
export * from './chat';

6
packages/@n8n/chat/src/shims.d.ts vendored Normal file
View file

@ -0,0 +1,6 @@
declare module '*.vue' {
import { defineComponent } from 'vue';
const component: ReturnType<typeof defineComponent>;
export default component;
}

View 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>;
}

View file

@ -0,0 +1,4 @@
export * from './chat';
export * from './messages';
export * from './options';
export * from './webhook';

View file

@ -0,0 +1,6 @@
export interface ChatMessage {
id: string;
text: string;
createdAt: string;
sender: 'user' | 'bot';
}

View 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?: {};
}

View 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;
}

View 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,
};
}

View file

@ -0,0 +1,2 @@
export * from './event-bus';
export * from './mount';

View 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);
}
}

View 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"]
}

View 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',
},
},
},
},
});

View 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$/],
},
},
}),
);

View 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

View 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',
},
},
],
};

View file

@ -0,0 +1,8 @@
node_modules
.DS_Store
.tmp
tmp
dist
npm-debug.log*
yarn.lock
.vscode/launch.json

View file

@ -0,0 +1,2 @@
.DS_Store
*.tsbuildinfo

View 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,
};

View file

@ -0,0 +1,7 @@
{
"recommendations": [
"dbaeumer.vscode-eslint",
"EditorConfig.EditorConfig",
"esbenp.prettier-vscode",
]
}

View 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 licensors 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.

View 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/).

View file

@ -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,
},
},
};
}

View file

@ -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'],
},
},
};
}

View file

@ -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',
},
};
}

View file

@ -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',
},
};
}

View file

@ -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',
},
};
}

View file

@ -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',
},
},
};
}

View file

@ -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',
},
},
};
}

View file

@ -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 ',
},
};
}

View file

@ -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}}',
},
},
};
}

View file

@ -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}}',
},
};
}

View file

@ -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',
},
};
}

View 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));
}

View file

Some files were not shown because too many files have changed in this diff Show more