feat: Add Chat Trigger node (#7409)

Signed-off-by: Oleg Ivaniv <me@olegivaniv.com>
Co-authored-by: Jan Oberhauser <jan.oberhauser@gmail.com>
Co-authored-by: Jesper Bylund <mail@jesperbylund.com>
Co-authored-by: OlegIvaniv <me@olegivaniv.com>
Co-authored-by: Deborah <deborah@starfallprojects.co.uk>
Co-authored-by: Jan Oberhauser <janober@users.noreply.github.com>
Co-authored-by: Jon <jonathan.bennetts@gmail.com>
Co-authored-by: कारतोफ्फेलस्क्रिप्ट™ <aditya@netroy.in>
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:
Alex Grozav
2024-01-09 13:11:39 +02:00
committed by GitHub
parent 1387541e33
commit af49e95cc7
90 changed files with 2671 additions and 668 deletions

View File

@@ -2,9 +2,9 @@
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.
Create a n8n workflow which you want to execute via chat. The workflow has to be triggered using a **Chat Trigger** 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.
Open the **Chat Trigger** node and add your domain to the **Allowed Origins (CORS)** field. This makes sure that only requests from your domain are accepted.
[See example workflow](https://github.com/n8n-io/n8n/blob/master/packages/%40n8n/chat/resources/workflow.json)
@@ -17,8 +17,6 @@ Each request is accompanied by an `action` query parameter, where `action` can b
- `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.
@@ -106,6 +104,10 @@ createChat({
},
target: '#n8n-chat',
mode: 'window',
chatInputKey: 'chatInput',
chatSessionKey: 'sessionId',
metadata: {},
showWelcomeScreen: false,
defaultLanguage: 'en',
initialMessages: [
'Hi there! 👋',
@@ -148,6 +150,21 @@ createChat({
- 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.
### `showWelcomeScreen`
- **Type**: `boolean`
- **Default**: `false`
- **Description**: Whether to show the welcome screen when the Chat window is opened.
### `chatSessionKey`
- **Type**: `string`
- **Default**: `'sessionId'`
- **Description**: The key to use for sending the chat history session ID for the AI Memory node.
### `chatInputKey`
- **Type**: `string`
- **Default**: `'chatInput'`
- **Description**: The key to use for sending the chat input for the AI Agent node.
### `defaultLanguage`
- **Type**: `string`
- **Default**: `'en'`

View File

@@ -0,0 +1,21 @@
import { defineBuildConfig } from 'unbuild';
export default defineBuildConfig({
entries: [
{
builder: 'mkdist',
format: 'esm',
input: './src',
outDir: './tmp/lib',
},
{
builder: 'mkdist',
format: 'cjs',
input: './src',
outDir: './tmp/cjs',
},
],
clean: true,
declaration: true,
failOnWarn: false,
});

View File

@@ -3,9 +3,11 @@
"version": "0.6.0",
"scripts": {
"dev": "pnpm run storybook",
"build": "pnpm type-check && pnpm build:vite && pnpm build:prepare",
"build:vite": "vite build && npm run build:vite:full",
"build": "pnpm type-check && pnpm build:vite && pnpm run build:individual && npm run build:prepare",
"build:full": "pnpm type-check && pnpm build:vite && pnpm build:vite:full && pnpm run build:individual && npm run build:prepare",
"build:vite": "vite build",
"build:vite:full": "INCLUDE_VUE=true vite build",
"build:individual": "unbuild",
"build:prepare": "node scripts/postbuild.js",
"build:pack": "node scripts/pack.js",
"preview": "vite preview",
@@ -16,7 +18,7 @@
"format": "prettier --write src/",
"storybook": "storybook dev -p 6006 --no-open",
"build:storybook": "storybook build",
"release": "pnpm run build && cd dist && pnpm publish"
"release": "pnpm run build:full && cd dist && pnpm publish"
},
"main": "./chat.umd.cjs",
"module": "./chat.es.js",
@@ -29,6 +31,10 @@
"./style.css": {
"import": "./style.css",
"require": "./style.css"
},
"./*": {
"import": "./*",
"require": "./*"
}
},
"dependencies": {
@@ -39,8 +45,8 @@
},
"devDependencies": {
"@iconify-json/mdi": "^1.1.54",
"n8n-design-system": "workspace:*",
"shelljs": "^0.8.5",
"unbuild": "^2.0.0",
"unplugin-icons": "^0.17.0",
"vite-plugin-dts": "^3.6.4"
},

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

View File

@@ -0,0 +1,238 @@
{
"name": "Hosted n8n AI Chat Manual",
"nodes": [
{
"parameters": {
"options": {}
},
"id": "e6043748-44fc-4019-9301-5690fe26c614",
"name": "OpenAI Chat Model",
"type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
"typeVersion": 1,
"position": [
860,
540
],
"credentials": {
"openAiApi": {
"id": "cIIkOhl7tUX1KsL6",
"name": "OpenAi account"
}
}
},
{
"parameters": {
"sessionKey": "={{ $json.sessionId }}"
},
"id": "0a68a59a-8ab6-4fa5-a1ea-b7f99a93109b",
"name": "Window Buffer Memory",
"type": "@n8n/n8n-nodes-langchain.memoryBufferWindow",
"typeVersion": 1,
"position": [
640,
540
]
},
{
"parameters": {
"text": "={{ $json.chatInput }}",
"options": {}
},
"id": "3d4e0fbf-d761-4569-b02e-f5c1eeb830c8",
"name": "AI Agent",
"type": "@n8n/n8n-nodes-langchain.agent",
"typeVersion": 1.1,
"position": [
840,
300
]
},
{
"parameters": {
"dataType": "string",
"value1": "={{ $json.action }}",
"rules": {
"rules": [
{
"value2": "loadPreviousSession",
"outputKey": "loadPreviousSession"
},
{
"value2": "sendMessage",
"outputKey": "sendMessage"
}
]
}
},
"id": "84213c7b-abc7-4f40-9567-cd3484a4ae6b",
"name": "Switch",
"type": "n8n-nodes-base.switch",
"typeVersion": 2,
"position": [
300,
280
]
},
{
"parameters": {
"simplifyOutput": false
},
"id": "3be7f076-98ed-472a-80b6-bf8d9538ac87",
"name": "Chat Messages Retriever",
"type": "@n8n/n8n-nodes-langchain.memoryChatRetriever",
"typeVersion": 1,
"position": [
620,
140
]
},
{
"parameters": {
"options": {}
},
"id": "3417c644-8a91-4524-974a-45b4a46d0e2e",
"name": "Respond to Webhook",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1,
"position": [
1240,
140
]
},
{
"parameters": {
"public": true,
"authentication": "n8nUserAuth",
"options": {
"loadPreviousSession": "manually",
"responseMode": "responseNode"
}
},
"id": "1b30c239-a819-45b4-b0ae-bdd5b92a5424",
"name": "Chat Trigger",
"type": "@n8n/n8n-nodes-langchain.chatTrigger",
"typeVersion": 1,
"position": [
80,
280
],
"webhookId": "ed3dea26-7d68-42b3-9032-98fe967d441d"
},
{
"parameters": {
"aggregate": "aggregateAllItemData",
"options": {}
},
"id": "79672cf0-686b-41eb-90ae-fd31b6da837d",
"name": "Aggregate",
"type": "n8n-nodes-base.aggregate",
"typeVersion": 1,
"position": [
1000,
140
]
}
],
"pinData": {},
"connections": {
"OpenAI Chat Model": {
"ai_languageModel": [
[
{
"node": "AI Agent",
"type": "ai_languageModel",
"index": 0
}
]
]
},
"Window Buffer Memory": {
"ai_memory": [
[
{
"node": "AI Agent",
"type": "ai_memory",
"index": 0
},
{
"node": "Chat Messages Retriever",
"type": "ai_memory",
"index": 0
}
]
]
},
"Switch": {
"main": [
[
{
"node": "Chat Messages Retriever",
"type": "main",
"index": 0
}
],
[
{
"node": "AI Agent",
"type": "main",
"index": 0
}
]
]
},
"Chat Messages Retriever": {
"main": [
[
{
"node": "Aggregate",
"type": "main",
"index": 0
}
]
]
},
"AI Agent": {
"main": [
[
{
"node": "Respond to Webhook",
"type": "main",
"index": 0
}
]
]
},
"Chat Trigger": {
"main": [
[
{
"node": "Switch",
"type": "main",
"index": 0
}
]
]
},
"Aggregate": {
"main": [
[
{
"node": "Respond to Webhook",
"type": "main",
"index": 0
}
]
]
}
},
"active": true,
"settings": {
"executionOrder": "v1"
},
"versionId": "425c0efe-3aa0-4e0e-8c06-abe12234b1fd",
"id": "1569HF92Y02EUtsU",
"meta": {
"instanceId": "374b43d8b8d6299cc777811a4ad220fc688ee2d54a308cfb0de4450a5233ca9e"
},
"tags": []
}

View File

@@ -1,245 +1,77 @@
{
"name": "AI Webhook Chat",
"name": "Hosted n8n AI 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",
"id": "4c109d13-62a2-4e23-9979-e50201db743d",
"name": "OpenAI Chat Model",
"type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
"typeVersion": 1,
"position": [
1720,
620
640,
540
],
"credentials": {
"openAiApi": {
"id": "B5Fiv70Adfg6htxn",
"name": "Alex's OpenAI Account"
"id": "cIIkOhl7tUX1KsL6",
"name": "OpenAi account"
}
}
},
{
"parameters": {
"sessionKey": "={{ $json.body.sessionId }}"
"sessionKey": "={{ $json.sessionId }}"
},
"id": "ebc23ffa-3bcf-494f-bcb8-51a5fff91885",
"id": "b416df7b-4802-462f-8f74-f0a71dc4c0be",
"name": "Window Buffer Memory",
"type": "@n8n/n8n-nodes-langchain.memoryBufferWindow",
"typeVersion": 1,
"position": [
1920,
620
340,
540
]
},
{
"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 }}",
"text": "={{ $json.chatInput }}",
"options": {}
},
"id": "f28f5c00-c742-41d5-8ddb-f0f59ab111a3",
"name": "Agent",
"id": "4de25807-a2ef-4453-900e-e00e0021ecdc",
"name": "AI Agent",
"type": "@n8n/n8n-nodes-langchain.agent",
"typeVersion": 1,
"typeVersion": 1.1,
"position": [
1780,
340
620,
300
]
},
{
"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();"
"public": true,
"options": {
"loadPreviousSession": "memory"
}
},
"id": "415c071b-18b2-4ac5-8634-e3d939bf36ac",
"name": "Transform request body",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"id": "5a9612ae-51c1-4be2-bd8b-8556872d1149",
"name": "Chat Trigger",
"type": "@n8n/n8n-nodes-langchain.chatTrigger",
"typeVersion": 1,
"position": [
1120,
200
]
340,
300
],
"webhookId": "f406671e-c954-4691-b39a-66c90aa2f103"
}
],
"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": {
"OpenAI Chat Model": {
"ai_languageModel": [
[
{
"node": "Agent",
"node": "AI Agent",
"type": "ai_languageModel",
"index": 0
}
@@ -250,29 +82,23 @@
"ai_memory": [
[
{
"node": "Agent",
"node": "AI Agent",
"type": "ai_memory",
"index": 0
},
{
"node": "Chat Trigger",
"type": "ai_memory",
"index": 0
}
]
]
},
"Agent": {
"Chat Trigger": {
"main": [
[
{
"node": "Respond to Webhook",
"type": "main",
"index": 0
}
]
]
},
"Transform request body": {
"main": [
[
{
"node": "Chatbot Action",
"node": "AI Agent",
"type": "main",
"index": 0
}
@@ -284,8 +110,8 @@
"settings": {
"executionOrder": "v1"
},
"versionId": "12c145a2-74bf-48b5-a87a-ba707949eaed",
"id": "L3FlJuFOxZcHtoFT",
"versionId": "6076136f-fdb4-48d9-b483-d1c24c95ef9e",
"id": "zaBHnDtj22BzEQ6K",
"meta": {
"instanceId": "374b43d8b8d6299cc777811a4ad220fc688ee2d54a308cfb0de4450a5233ca9e"
},

View File

@@ -1,9 +1,13 @@
const path = require('path');
const shelljs = require('shelljs');
const glob = require('fast-glob');
const rootDirPath = path.resolve(__dirname, '..');
const n8nRootDirPath = path.resolve(rootDirPath, '..', '..', '..');
const distDirPath = path.resolve(rootDirPath, 'dist');
const srcDirPath = path.resolve(rootDirPath, 'src');
const libDirPath = path.resolve(rootDirPath, 'tmp', 'lib');
const cjsDirPath = path.resolve(rootDirPath, 'tmp', 'cjs');
const packageJsonFilePath = path.resolve(rootDirPath, 'package.json');
const readmeFilePath = path.resolve(rootDirPath, 'README.md');
@@ -14,3 +18,19 @@ shelljs.cp(readmeFilePath, distDirPath);
shelljs.cp(licenseFilePath, distDirPath);
shelljs.mv(path.resolve(distDirPath, 'src'), path.resolve(distDirPath, 'types'));
function moveFiles(files, from, to) {
files.forEach((file) => {
const toFile = file.replace(from, to);
shelljs.mkdir('-p', path.dirname(toFile));
shelljs.mv(file, toFile);
});
}
const cjsFiles = glob.sync(path.resolve(cjsDirPath, '**', '*'));
moveFiles(cjsFiles, 'tmp/cjs', 'dist');
shelljs.rm('-rf', cjsDirPath);
const libFiles = glob.sync(path.resolve(libDirPath, '**/*'));
moveFiles(libFiles, 'tmp/lib', 'dist');
shelljs.rm('-rf', libDirPath);

View File

@@ -1,10 +1,10 @@
<script lang="ts" setup>
import { Chat, ChatWindow } from '@/components';
import { Chat, ChatWindow } from '@n8n/chat/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';
import { useOptions } from '@n8n/chat/composables';
defineProps({});

View File

@@ -1,10 +1,10 @@
/* eslint-disable @typescript-eslint/naming-convention */
import type { StoryObj } from '@storybook/vue3';
import type { ChatOptions } from '@/types';
import { createChat } from '@/index';
import type { ChatOptions } from '@n8n/chat/types';
import { createChat } from '@n8n/chat/index';
import { onMounted } from 'vue';
const webhookUrl = 'http://localhost:5678/webhook/513107b3-6f3a-4a1e-af21-659f0ed14183';
const webhookUrl = 'http://localhost:5678/webhook/f406671e-c954-4691-b39a-66c90aa2f103/chat';
const meta = {
title: 'Chat',

View File

@@ -14,9 +14,8 @@ import {
getChatWrapper,
getGetStartedButton,
getMountingTarget,
} from '@/__tests__/utils';
import { createChat } from '@/index';
import { useChat } from '@/composables';
} from '@n8n/chat/__tests__/utils';
import { createChat } from '@n8n/chat/index';
describe('createChat()', () => {
let app: ReturnType<typeof createChat>;
@@ -77,6 +76,7 @@ describe('createChat()', () => {
app = createChat({
mode: 'fullscreen',
showWelcomeScreen: true,
});
const getStartedButton = getGetStartedButton();
@@ -85,7 +85,9 @@ describe('createChat()', () => {
expect(fetchSpy.mock.calls[0][1]).toEqual(
expect.objectContaining({
method: 'POST',
headers: {},
headers: {
'Content-Type': 'application/json',
},
body: expect.stringContaining('"action":"loadPreviousSession"') as unknown,
mode: 'cors',
cache: 'no-cache',
@@ -112,9 +114,6 @@ describe('createChat()', () => {
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();
@@ -144,12 +143,10 @@ describe('createChat()', () => {
}
expect(getChatMessageTyping()).not.toBeInTheDocument();
const getStartedButton = getGetStartedButton();
await fireEvent.click(getStartedButton as HTMLElement);
expect(getChatMessages().length).toBe(2);
await waitFor(() => expect(getChatInputTextarea()).toBeInTheDocument());
const textarea = getChatInputTextarea();
const sendButton = getChatInputSendButton();
await fireEvent.update(textarea as HTMLElement, input);
@@ -159,7 +156,9 @@ describe('createChat()', () => {
expect(fetchSpy.mock.calls[1][1]).toEqual(
expect.objectContaining({
method: 'POST',
headers: {},
headers: {
'Content-Type': 'application/json',
},
body: expect.stringMatching(/"action":"sendMessage"/) as unknown,
mode: 'cors',
cache: 'no-cache',
@@ -182,9 +181,6 @@ describe('createChat()', () => {
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))
@@ -199,8 +195,7 @@ describe('createChat()', () => {
await fireEvent.click(trigger as HTMLElement);
}
const getStartedButton = getGetStartedButton();
await fireEvent.click(getStartedButton as HTMLElement);
await waitFor(() => expect(getChatInputTextarea()).toBeInTheDocument());
const textarea = getChatInputTextarea();
const sendButton = getChatInputSendButton();

View File

@@ -1,4 +1,4 @@
import { createChat } from '@/index';
import { createChat } from '@n8n/chat/index';
export function createTestChat(options: Parameters<typeof createChat>[0] = {}): {
unmount: () => void;

View File

@@ -1,4 +1,4 @@
import type { LoadPreviousSessionResponse, SendMessageResponse } from '@/types';
import type { LoadPreviousSessionResponse, SendMessageResponse } from '@n8n/chat/types';
export function createFetchResponse<T>(data: T) {
return async () =>

View File

@@ -1,5 +1,5 @@
import { screen } from '@testing-library/vue';
import { defaultMountingTarget } from '@/constants';
import { defaultMountingTarget } from '@n8n/chat/constants';
export function getMountingTarget(target = defaultMountingTarget) {
return document.querySelector(target);

View File

@@ -10,6 +10,7 @@ export async function authenticatedFetch<T>(...args: Parameters<typeof fetch>):
mode: 'cors',
cache: 'no-cache',
headers: {
'Content-Type': 'application/json',
...(accessToken ? { authorization: `Bearer ${accessToken}` } : {}),
...args[1]?.headers,
},
@@ -18,14 +19,12 @@ export async function authenticatedFetch<T>(...args: Parameters<typeof fetch>):
return (await response.json()) as Promise<T>;
}
export async function get<T>(
url: string,
query: Record<string, string> = {},
options: RequestInit = {},
) {
export async function get<T>(url: string, query: object = {}, options: RequestInit = {}) {
let resolvedUrl = url;
if (Object.keys(query).length > 0) {
resolvedUrl = `${resolvedUrl}?${new URLSearchParams(query).toString()}`;
resolvedUrl = `${resolvedUrl}?${new URLSearchParams(
query as Record<string, string>,
).toString()}`;
}
return authenticatedFetch<T>(resolvedUrl, { ...options, method: 'GET' });

View File

@@ -1,5 +1,9 @@
import { get, post } from '@/api/generic';
import type { ChatOptions, LoadPreviousSessionResponse, SendMessageResponse } from '@/types';
import { get, post } from '@n8n/chat/api/generic';
import type {
ChatOptions,
LoadPreviousSessionResponse,
SendMessageResponse,
} from '@n8n/chat/types';
export async function loadPreviousSession(sessionId: string, options: ChatOptions) {
const method = options.webhookConfig?.method === 'POST' ? post : get;
@@ -7,7 +11,8 @@ export async function loadPreviousSession(sessionId: string, options: ChatOption
`${options.webhookUrl}`,
{
action: 'loadPreviousSession',
sessionId,
[options.chatSessionKey as string]: sessionId,
...(options.metadata ? { metadata: options.metadata } : {}),
},
{
headers: options.webhookConfig?.headers,
@@ -21,8 +26,9 @@ export async function sendMessage(message: string, sessionId: string, options: C
`${options.webhookUrl}`,
{
action: 'sendMessage',
sessionId,
message,
[options.chatSessionKey as string]: sessionId,
[options.chatInputKey as string]: message,
...(options.metadata ? { metadata: options.metadata } : {}),
},
{
headers: options.webhookConfig?.headers,

View File

@@ -1,24 +1,18 @@
<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 Layout from '@n8n/chat/components/Layout.vue';
import GetStarted from '@n8n/chat/components/GetStarted.vue';
import GetStartedFooter from '@n8n/chat/components/GetStartedFooter.vue';
import MessagesList from '@n8n/chat/components/MessagesList.vue';
import Input from '@n8n/chat/components/Input.vue';
import { nextTick, onMounted } from 'vue';
import { useI18n, useChat } from '@/composables';
import { chatEventBus } from '@/event-buses';
import { useI18n, useChat, useOptions } from '@n8n/chat/composables';
import { chatEventBus } from '@n8n/chat/event-buses';
const { t } = useI18n();
const chatStore = useChat();
const { messages, currentSessionId } = chatStore;
async function initialize() {
await chatStore.loadPreviousSession();
void nextTick(() => {
chatEventBus.emit('scrollToBottom');
});
}
const { options } = useOptions();
async function getStarted() {
void chatStore.startNewSession();
@@ -27,18 +21,28 @@ async function getStarted() {
});
}
onMounted(() => {
void initialize();
async function initialize() {
await chatStore.loadPreviousSession();
void nextTick(() => {
chatEventBus.emit('scrollToBottom');
});
}
onMounted(async () => {
await initialize();
if (!options.showWelcomeScreen && !currentSessionId.value) {
await getStarted();
}
});
</script>
<template>
<Layout class="chat-wrapper">
<template #header v-if="!currentSessionId">
<template #header>
<h1>{{ t('title') }}</h1>
<p>{{ t('subtitle') }}</p>
</template>
<GetStarted v-if="!currentSessionId" @click:button="getStarted" />
<GetStarted v-if="!currentSessionId && options.showWelcomeScreen" @click:button="getStarted" />
<MessagesList v-else :messages="messages" />
<template #footer>
<Input v-if="currentSessionId" />

View File

@@ -3,9 +3,9 @@
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 Chat from '@n8n/chat/components/Chat.vue';
import { nextTick, ref } from 'vue';
import { chatEventBus } from '@/event-buses';
import { chatEventBus } from '@n8n/chat/event-buses';
const isOpen = ref(false);

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import Button from '@/components/Button.vue';
import { useI18n } from '@/composables';
import Button from '@n8n/chat/components/Button.vue';
import { useI18n } from '@n8n/chat/composables';
const { t } = useI18n();
</script>

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { useI18n } from '@/composables';
import PoweredBy from '@/components/PoweredBy.vue';
import { useI18n } from '@n8n/chat/composables';
import PoweredBy from '@n8n/chat/components/PoweredBy.vue';
const { t, te } = useI18n();
</script>

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
// eslint-disable-next-line import/no-unresolved
import IconSend from 'virtual:icons/mdi/send';
import { useI18n, useChat } from '@/composables';
import { useI18n, useChat } from '@n8n/chat/composables';
import { computed, ref } from 'vue';
const chatStore = useChat();

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue';
import { chatEventBus } from '@/event-buses';
import { chatEventBus } from '@n8n/chat/event-buses';
const chatBodyRef = ref<HTMLElement | null>(null);

View File

@@ -1,6 +1,6 @@
<script lang="ts" setup>
/* eslint-disable @typescript-eslint/naming-convention */
import type { ChatMessage } from '@/types';
import type { ChatMessage } from '@n8n/chat/types';
import type { PropType } from 'vue';
import { computed, toRefs } from 'vue';
import VueMarkdown from 'vue-markdown-render';
@@ -15,6 +15,10 @@ const props = defineProps({
const { message } = toRefs(props);
const messageText = computed(() => {
return message.value.text || '&lt;Empty response&gt;';
});
const classes = computed(() => {
return {
'chat-message-from-user': message.value.sender === 'user',
@@ -39,7 +43,7 @@ const markdownOptions = {
<slot>
<vue-markdown
class="chat-message-markdown"
:source="message.text"
:source="messageText"
:options="markdownOptions"
/>
</slot>

View File

@@ -1,5 +1,5 @@
<script lang="ts" setup>
import type { ChatMessage } from '@/types';
import type { ChatMessage } from '@n8n/chat/types';
import { Message } from './index';
import type { PropType } from 'vue';
import { computed } from 'vue';

View File

@@ -1,9 +1,9 @@
<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';
import Message from '@n8n/chat/components/Message.vue';
import type { ChatMessage } from '@n8n/chat/types';
import MessageTyping from '@n8n/chat/components/MessageTyping.vue';
import { useChat } from '@n8n/chat/composables';
defineProps({
messages: {

View File

@@ -1,6 +1,6 @@
import { inject } from 'vue';
import { ChatSymbol } from '@/constants';
import type { Chat } from '@/types';
import { ChatSymbol } from '@n8n/chat/constants';
import type { Chat } from '@n8n/chat/types';
export function useChat() {
return inject(ChatSymbol) as Chat;

View File

@@ -1,4 +1,4 @@
import { useOptions } from '@/composables/useOptions';
import { useOptions } from '@n8n/chat/composables/useOptions';
export function useI18n() {
const { options } = useOptions();

View File

@@ -1,6 +1,6 @@
import { inject } from 'vue';
import { ChatOptionsSymbol } from '@/constants';
import type { ChatOptions } from '@/types';
import { ChatOptionsSymbol } from '@n8n/chat/constants';
import type { ChatOptions } from '@n8n/chat/types';
export function useOptions() {
const options = inject(ChatOptionsSymbol) as ChatOptions;

View File

@@ -1,4 +1,4 @@
import type { ChatOptions } from '@/types';
import type { ChatOptions } from '@n8n/chat/types';
export const defaultOptions: ChatOptions = {
webhookUrl: 'http://localhost:5678',
@@ -8,7 +8,11 @@ export const defaultOptions: ChatOptions = {
},
target: '#n8n-chat',
mode: 'window',
loadPreviousSession: true,
chatInputKey: 'chatInput',
chatSessionKey: 'sessionId',
defaultLanguage: 'en',
showWelcomeScreen: false,
initialMessages: ['Hi there! 👋', 'My name is Nathan. How can I assist you today?'],
i18n: {
en: {

View File

@@ -1,5 +1,5 @@
import type { InjectionKey } from 'vue';
import type { Chat, ChatOptions } from '@/types';
import type { Chat, ChatOptions } from '@n8n/chat/types';
// eslint-disable-next-line @typescript-eslint/naming-convention
export const ChatSymbol = 'Chat' as unknown as InjectionKey<Chat>;

View File

@@ -0,0 +1,36 @@
: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 @@
@import 'tokens';

View File

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

View File

@@ -2,10 +2,10 @@ 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';
import type { ChatOptions } from '@n8n/chat/types';
import { defaultMountingTarget, defaultOptions } from '@n8n/chat/constants';
import { createDefaultMountingTarget } from '@n8n/chat/utils';
import { ChatPlugin } from '@n8n/chat/plugins';
export function createChat(options?: Partial<ChatOptions>) {
const resolvedOptions: ChatOptions = {

View File

@@ -2,39 +2,4 @@
@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;
}
@import 'css';

View File

@@ -1,10 +1,10 @@
import type { Plugin } from 'vue';
import { computed, nextTick, ref } from 'vue';
import type { ChatMessage, ChatOptions } from '@/types';
import type { ChatMessage, ChatOptions } from '@n8n/chat/types';
import { v4 as uuidv4 } from 'uuid';
import { chatEventBus } from '@/event-buses';
import * as api from '@/api';
import { ChatOptionsSymbol, ChatSymbol, localStorageSessionIdKey } from '@/constants';
import { chatEventBus } from '@n8n/chat/event-buses';
import * as api from '@n8n/chat/api';
import { ChatOptionsSymbol, ChatSymbol, localStorageSessionIdKey } from '@n8n/chat/constants';
// eslint-disable-next-line @typescript-eslint/naming-convention
export const ChatPlugin: Plugin<ChatOptions> = {
@@ -61,6 +61,10 @@ export const ChatPlugin: Plugin<ChatOptions> = {
}
async function loadPreviousSession() {
if (!options.loadPreviousSession) {
return;
}
const sessionId = localStorage.getItem(localStorageSessionIdKey) ?? uuidv4();
const previousMessagesResponse = await api.loadPreviousSession(sessionId, options);
const timestamp = new Date().toISOString();

View File

@@ -1,4 +1,4 @@
import type { ChatMessage } from '@/types/messages';
import type { ChatMessage } from '@n8n/chat/types/messages';
import type { Ref } from 'vue';
export interface Chat {
@@ -6,7 +6,7 @@ export interface Chat {
messages: Ref<ChatMessage[]>;
currentSessionId: Ref<string | null>;
waitingForResponse: Ref<boolean>;
loadPreviousSession: () => Promise<string>;
loadPreviousSession: () => Promise<string | undefined>;
startNewSession: () => Promise<void>;
sendMessage: (text: string) => Promise<void>;
}

View File

@@ -6,8 +6,13 @@ export interface ChatOptions {
};
target?: string | Element;
mode?: 'window' | 'fullscreen';
showWelcomeScreen?: boolean;
loadPreviousSession?: boolean;
chatInputKey?: string;
chatSessionKey?: string;
defaultLanguage?: 'en';
initialMessages?: string[];
metadata?: Record<string, unknown>;
i18n: Record<
string,
{

View File

@@ -14,7 +14,7 @@
"types": ["vitest/globals", "unplugin-icons/types/vue"],
"paths": {
"@/*": ["src/*"],
"n8n-design-system/*": ["../design-system/src/*"]
"@n8n/chat/*": ["src/*"]
},
"lib": ["esnext", "dom", "dom.iterable", "scripthost"],
// TODO: remove all options below this line

View File

@@ -7,6 +7,7 @@ import icons from 'unplugin-icons/vite';
import dts from 'vite-plugin-dts';
const includeVue = process.env.INCLUDE_VUE === 'true';
const srcPath = fileURLToPath(new URL('./src', import.meta.url));
// https://vitejs.dev/config/
export default defineConfig({
@@ -19,7 +20,8 @@ export default defineConfig({
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
'@': srcPath,
'@n8n/chat': srcPath,
},
},
define: {