refactor: Move @n8n/chat package to frontend/@n8n (no-changelog) (#13425)

This commit is contained in:
Alex Grozav
2025-02-24 21:19:51 +02:00
committed by GitHub
parent 06572efad3
commit 37d4b00e3f
80 changed files with 289 additions and 323 deletions

View File

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

View File

@@ -0,0 +1,10 @@
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'),
};

28
packages/frontend/@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,4 @@
import { sharedConfig } from '@n8n/storybook/main';
const config = { ...sharedConfig };
export default config;

View File

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

View File

@@ -0,0 +1,15 @@
import type { Preview } from '@storybook/vue3';
import './preview.scss';
const preview: Preview = {
parameters: {
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,280 @@
# n8n Chat
This is an embeddable Chat widget for n8n. It allows the execution of AI-Powered Workflows through a Chat window.
**Windowed Example**
![n8n Chat Windowed](https://raw.githubusercontent.com/n8n-io/n8n/master/packages/%40n8n/chat/resources/images/windowed.png)
**Fullscreen Example**
![n8n Chat Fullscreen](https://raw.githubusercontent.com/n8n-io/n8n/master/packages/%40n8n/chat/resources/images/fullscreen.png)
## Prerequisites
Create a n8n workflow which you want to execute via chat. The workflow has to be triggered using a **Chat Trigger** node.
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)
> 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
## 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/dist/style.css" rel="stylesheet" />
<script type="module">
import { createChat } from 'https://cdn.jsdelivr.net/npm/@n8n/chat/dist/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',
chatInputKey: 'chatInput',
chatSessionKey: 'sessionId',
loadPreviousSession: true,
metadata: {},
showWelcomeScreen: false,
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.
### `showWelcomeScreen`
- **Type**: `boolean`
- **Default**: `false`
- **Description**: Whether to show the welcome screen when the Chat window is opened.
### `chatInputKey`
- **Type**: `string`
- **Default**: `'chatInput'`
- **Description**: The key to use for sending the chat input for the AI Agent node.
### `chatSessionKey`
- **Type**: `string`
- **Default**: `'sessionId'`
- **Description**: The key to use for sending the chat history session ID for the AI Memory node.
### `loadPreviousSession`
- **Type**: `boolean`
- **Default**: `true`
- **Description**: Whether to load previous messages (chat context).
### `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.
### `allowFileUploads`
- **Type**: `Ref<boolean> | boolean`
- **Default**: `false`
- **Description**: Whether to allow file uploads in the chat. If set to `true`, users will be able to upload files through the chat interface.
### `allowedFilesMimeTypes`
- **Type**: `Ref<string> | string`
- **Default**: `''`
- **Description**: A comma-separated list of allowed MIME types for file uploads. Only applicable if `allowFileUploads` is set to `true`. If left empty, all file types are allowed. For example: `'image/*,application/pdf'`.
## 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--header-height: auto;
--chat--header--padding: var(--chat--spacing);
--chat--header--background: var(--chat--color-dark);
--chat--header--color: var(--chat--color-light);
--chat--header--border-top: none;
--chat--header--border-bottom: none;
--chat--header--border-bottom: none;
--chat--header--border-bottom: none;
--chat--heading--font-size: 2em;
--chat--header--color: var(--chat--color-light);
--chat--subtitle--font-size: inherit;
--chat--subtitle--line-height: 1.8;
--chat--textarea--height: 50px;
--chat--message--font-size: 1rem;
--chat--message--padding: var(--chat--spacing);
--chat--message--border-radius: var(--chat--border-radius);
--chat--message-line-height: 1.8;
--chat--message--bot--background: var(--chat--color-white);
--chat--message--bot--color: var(--chat--color-dark);
--chat--message--bot--border: none;
--chat--message--user--background: var(--chat--color-secondary);
--chat--message--user--color: var(--chat--color-white);
--chat--message--user--border: none;
--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
You can find the license information [here](https://github.com/n8n-io/n8n/blob/master/README.md#license)

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,61 @@
{
"name": "@n8n/chat",
"version": "0.34.0",
"scripts": {
"dev": "pnpm run storybook",
"build": "pnpm build:vite && pnpm build:bundle",
"build:vite": "cross-env vite build",
"build:bundle": "cross-env INCLUDE_VUE=true vite build",
"preview": "vite preview",
"test:dev": "vitest",
"test": "vitest run",
"typecheck": "vue-tsc --noEmit",
"lint": "eslint . --ext .js,.ts,.vue --quiet",
"lintfix": "eslint . --ext .js,.ts,.vue --fix",
"format": "biome format --write src .storybook && prettier --write src/ --ignore-path ../../.prettierignore",
"format:check": "biome ci src .storybook && prettier --check src/ --ignore-path ../../.prettierignore",
"storybook": "storybook dev -p 6006 --no-open",
"build:storybook": "storybook build"
},
"main": "./dist/chat.umd.js",
"module": "./dist/chat.es.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/chat.es.js",
"require": "./dist/chat.umd.js",
"types": "./dist/index.d.ts"
},
"./style.css": {
"import": "./dist/style.css",
"require": "./dist/style.css"
},
"./*": {
"import": "./*",
"require": "./*"
}
},
"dependencies": {
"@vueuse/core": "^10.11.0",
"highlight.js": "catalog:frontend",
"markdown-it-link-attributes": "^4.0.1",
"uuid": "catalog:",
"vue": "catalog:frontend",
"vue-markdown-render": "catalog:frontend"
},
"devDependencies": {
"@iconify-json/mdi": "^1.1.54",
"@n8n/storybook": "workspace:*",
"@vitejs/plugin-vue": "catalog:frontend",
"@vitest/coverage-v8": "catalog:frontend",
"unplugin-icons": "^0.19.0",
"vite": "catalog:frontend",
"vitest": "catalog:frontend",
"vite-plugin-dts": "^4.3.0",
"vue-tsc": "catalog:frontend"
},
"files": [
"README.md",
"dist"
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

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

@@ -0,0 +1,107 @@
{
"name": "Hosted n8n AI Chat",
"nodes": [
{
"parameters": {
"options": {}
},
"id": "4c109d13-62a2-4e23-9979-e50201db743d",
"name": "OpenAI Chat Model",
"type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
"typeVersion": 1,
"position": [640, 540],
"credentials": {
"openAiApi": {
"id": "cIIkOhl7tUX1KsL6",
"name": "OpenAi account"
}
}
},
{
"parameters": {
"sessionKey": "={{ $json.sessionId }}"
},
"id": "b416df7b-4802-462f-8f74-f0a71dc4c0be",
"name": "Window Buffer Memory",
"type": "@n8n/n8n-nodes-langchain.memoryBufferWindow",
"typeVersion": 1,
"position": [340, 540]
},
{
"parameters": {
"text": "={{ $json.chatInput }}",
"options": {}
},
"id": "4de25807-a2ef-4453-900e-e00e0021ecdc",
"name": "AI Agent",
"type": "@n8n/n8n-nodes-langchain.agent",
"typeVersion": 1.1,
"position": [620, 300]
},
{
"parameters": {
"public": true,
"options": {
"loadPreviousSession": "memory"
}
},
"id": "5a9612ae-51c1-4be2-bd8b-8556872d1149",
"name": "Chat Trigger",
"type": "@n8n/n8n-nodes-langchain.chatTrigger",
"typeVersion": 1,
"position": [340, 300],
"webhookId": "f406671e-c954-4691-b39a-66c90aa2f103"
}
],
"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 Trigger",
"type": "ai_memory",
"index": 0
}
]
]
},
"Chat Trigger": {
"main": [
[
{
"node": "AI Agent",
"type": "main",
"index": 0
}
]
]
}
},
"active": true,
"settings": {
"executionOrder": "v1"
},
"versionId": "6076136f-fdb4-48d9-b483-d1c24c95ef9e",
"id": "zaBHnDtj22BzEQ6K",
"meta": {
"instanceId": "374b43d8b8d6299cc777811a4ad220fc688ee2d54a308cfb0de4450a5233ca9e"
},
"tags": []
}

View File

@@ -0,0 +1,24 @@
<script lang="ts" setup>
import hljs from 'highlight.js/lib/core';
import hljsJavascript from 'highlight.js/lib/languages/javascript';
import hljsXML from 'highlight.js/lib/languages/xml';
import { computed, onMounted } from 'vue';
import { Chat, ChatWindow } from '@n8n/chat/components';
import { useOptions } from '@n8n/chat/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,56 @@
/* eslint-disable @typescript-eslint/naming-convention */
import type { StoryObj } from '@storybook/vue3';
import { onMounted } from 'vue';
import { createChat } from '@n8n/chat/index';
import type { ChatOptions } from '@n8n/chat/types';
const webhookUrl = 'http://localhost:5678/webhook/f406671e-c954-4691-b39a-66c90aa2f103/chat';
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>,
};
export const WorkflowChat: Story = {
name: 'Workflow Chat',
args: {
webhookUrl: 'http://localhost:5678/webhook/ad324b56-3e40-4b27-874f-58d150504edc/chat',
mode: 'fullscreen',
allowedFilesMimeTypes: 'image/*,text/*,audio/*, application/pdf',
allowFileUploads: true,
showWelcomeScreen: false,
initialMessages: [],
} satisfies Partial<ChatOptions>,
};

View File

@@ -0,0 +1,219 @@
import { fireEvent, waitFor } from '@testing-library/vue';
import {
createFetchResponse,
createGetLatestMessagesResponse,
createSendMessageResponse,
getChatInputSendButton,
getChatInputTextarea,
getChatMessage,
getChatMessageByText,
getChatMessages,
getChatMessageTyping,
getChatWindowToggle,
getChatWindowWrapper,
getChatWrapper,
getGetStartedButton,
getMountingTarget,
} from '@n8n/chat/__tests__/utils';
import { createChat } from '@n8n/chat/index';
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',
showWelcomeScreen: true,
});
const getStartedButton = getGetStartedButton();
await fireEvent.click(getStartedButton as HTMLElement);
expect(fetchSpy.mock.calls[0][1]).toEqual(
expect.objectContaining({
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: expect.stringContaining('"action":"loadPreviousSession"') as unknown,
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);
}
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();
expect(getChatMessages().length).toBe(2);
await waitFor(() => expect(getChatInputTextarea()).toBeInTheDocument());
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: {
'Content-Type': 'application/json',
},
body: expect.stringMatching(/"action":"sendMessage"/) as unknown,
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 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);
}
await waitFor(() => expect(getChatInputTextarea()).toBeInTheDocument());
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,13 @@
import '@testing-library/jest-dom';
import '@testing-library/jest-dom';
import { configure } from '@testing-library/vue';
configure({ testIdAttribute: 'data-test-id' });
window.ResizeObserver =
window.ResizeObserver ||
vi.fn().mockImplementation(() => ({
disconnect: vi.fn(),
observe: vi.fn(),
unobserve: vi.fn(),
}));

View File

@@ -0,0 +1,16 @@
import { createChat } from '@n8n/chat/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 '@n8n/chat/types';
export function createFetchResponse<T>(data: T) {
return async () =>
({
json: async () => await 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,54 @@
import { screen } from '@testing-library/vue';
import { defaultMountingTarget } from '@n8n/chat/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,93 @@
async function getAccessToken() {
return '';
}
export async function authenticatedFetch<T>(...args: Parameters<typeof fetch>): Promise<T> {
const accessToken = await getAccessToken();
const body = args[1]?.body;
const headers: RequestInit['headers'] & { 'Content-Type'?: string } = {
...(accessToken ? { authorization: `Bearer ${accessToken}` } : {}),
...args[1]?.headers,
};
// Automatically set content type to application/json if body is FormData
if (body instanceof FormData) {
delete headers['Content-Type'];
} else {
headers['Content-Type'] = 'application/json';
}
const response = await fetch(args[0], {
...args[1],
mode: 'cors',
cache: 'no-cache',
headers,
});
return (await response.json()) as T;
}
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 as Record<string, string>,
).toString()}`;
}
return await authenticatedFetch<T>(resolvedUrl, { ...options, method: 'GET' });
}
export async function post<T>(url: string, body: object = {}, options: RequestInit = {}) {
return await authenticatedFetch<T>(url, {
...options,
method: 'POST',
body: JSON.stringify(body),
});
}
export async function postWithFiles<T>(
url: string,
body: Record<string, unknown> = {},
files: File[] = [],
options: RequestInit = {},
) {
const formData = new FormData();
for (const key in body) {
formData.append(key, body[key] as string);
}
for (const file of files) {
formData.append('files', file);
}
return await authenticatedFetch<T>(url, {
...options,
method: 'POST',
body: formData,
});
}
export async function put<T>(url: string, body: object = {}, options: RequestInit = {}) {
return await authenticatedFetch<T>(url, {
...options,
method: 'PUT',
body: JSON.stringify(body),
});
}
export async function patch<T>(url: string, body: object = {}, options: RequestInit = {}) {
return await authenticatedFetch<T>(url, {
...options,
method: 'PATCH',
body: JSON.stringify(body),
});
}
export async function del<T>(url: string, body: object = {}, options: RequestInit = {}) {
return await 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,57 @@
import { get, post, postWithFiles } 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;
return await method<LoadPreviousSessionResponse>(
`${options.webhookUrl}`,
{
action: 'loadPreviousSession',
[options.chatSessionKey as string]: sessionId,
...(options.metadata ? { metadata: options.metadata } : {}),
},
{
headers: options.webhookConfig?.headers,
},
);
}
export async function sendMessage(
message: string,
files: File[],
sessionId: string,
options: ChatOptions,
) {
if (files.length > 0) {
return await postWithFiles<SendMessageResponse>(
`${options.webhookUrl}`,
{
action: 'sendMessage',
[options.chatSessionKey as string]: sessionId,
[options.chatInputKey as string]: message,
...(options.metadata ? { metadata: options.metadata } : {}),
},
files,
{
headers: options.webhookConfig?.headers,
},
);
}
const method = options.webhookConfig?.method === 'POST' ? post : get;
return await method<SendMessageResponse>(
`${options.webhookUrl}`,
{
action: 'sendMessage',
[options.chatSessionKey as string]: sessionId,
[options.chatInputKey as string]: message,
...(options.metadata ? { metadata: options.metadata } : {}),
},
{
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,97 @@
<script setup lang="ts">
import Close from 'virtual:icons/mdi/close';
import { computed, nextTick, onMounted } from 'vue';
import GetStarted from '@n8n/chat/components/GetStarted.vue';
import GetStartedFooter from '@n8n/chat/components/GetStartedFooter.vue';
import Input from '@n8n/chat/components/Input.vue';
import Layout from '@n8n/chat/components/Layout.vue';
import MessagesList from '@n8n/chat/components/MessagesList.vue';
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;
const { options } = useOptions();
const showCloseButton = computed(() => options.mode === 'window' && options.showWindowCloseButton);
async function getStarted() {
if (!chatStore.startNewSession) {
return;
}
void chatStore.startNewSession();
void nextTick(() => {
chatEventBus.emit('scrollToBottom');
});
}
async function initialize() {
if (!chatStore.loadPreviousSession) {
return;
}
await chatStore.loadPreviousSession();
void nextTick(() => {
chatEventBus.emit('scrollToBottom');
});
}
function closeChat() {
chatEventBus.emit('close');
}
onMounted(async () => {
await initialize();
if (!options.showWelcomeScreen && !currentSessionId.value) {
await getStarted();
}
});
</script>
<template>
<Layout class="chat-wrapper">
<template #header>
<div class="chat-heading">
<h1>
{{ t('title') }}
</h1>
<button
v-if="showCloseButton"
class="chat-close-button"
:title="t('closeButtonTooltip')"
@click="closeChat"
>
<Close height="18" width="18" />
</button>
</div>
<p v-if="t('subtitle')">{{ t('subtitle') }}</p>
</template>
<GetStarted v-if="!currentSessionId && options.showWelcomeScreen" @click:button="getStarted" />
<MessagesList v-else :messages="messages" />
<template #footer>
<Input v-if="currentSessionId" />
<GetStartedFooter v-else />
</template>
</Layout>
</template>
<style lang="scss">
.chat-heading {
display: flex;
justify-content: space-between;
align-items: center;
}
.chat-close-button {
display: flex;
border: none;
background: none;
cursor: pointer;
&:hover {
color: var(--chat--close--button--color-hover, var(--chat--color-primary));
}
}
</style>

View File

@@ -0,0 +1,105 @@
<script setup lang="ts">
import IconDelete from 'virtual:icons/mdi/closeThick';
import IconFileImage from 'virtual:icons/mdi/fileImage';
import IconFileMusic from 'virtual:icons/mdi/fileMusic';
import IconFileText from 'virtual:icons/mdi/fileText';
import IconFileVideo from 'virtual:icons/mdi/fileVideo';
import IconPreview from 'virtual:icons/mdi/openInNew';
import { computed, type FunctionalComponent } from 'vue';
const props = defineProps<{
file: File;
isRemovable: boolean;
isPreviewable?: boolean;
}>();
const emit = defineEmits<{
remove: [value: File];
}>();
const iconMapper: Record<string, FunctionalComponent> = {
document: IconFileText,
audio: IconFileMusic,
image: IconFileImage,
video: IconFileVideo,
};
const TypeIcon = computed(() => {
const type = props.file?.type.split('/')[0];
return iconMapper[type] || IconFileText;
});
function onClick() {
if (props.isPreviewable) {
window.open(URL.createObjectURL(props.file));
}
}
function onDelete() {
emit('remove', props.file);
}
</script>
<template>
<div class="chat-file" @click="onClick">
<TypeIcon />
<p class="chat-file-name">{{ file.name }}</p>
<span v-if="isRemovable" class="chat-file-delete" @click.stop="onDelete">
<IconDelete />
</span>
<IconPreview v-else-if="isPreviewable" class="chat-file-preview" />
</div>
</template>
<style scoped lang="scss">
.chat-file {
display: flex;
align-items: center;
flex-wrap: nowrap;
width: fit-content;
max-width: 15rem;
padding: 0.5rem;
border-radius: 0.25rem;
gap: 0.25rem;
font-size: 0.75rem;
background: white;
color: var(--chat--color-dark);
border: 1px solid var(--chat--color-dark);
cursor: pointer;
}
.chat-file-name-tooltip {
overflow: hidden;
}
.chat-file-name {
overflow: hidden;
max-width: 100%;
text-overflow: ellipsis;
white-space: nowrap;
margin: 0;
}
.chat-file-delete,
.chat-file-preview {
background: none;
border: none;
display: block;
cursor: pointer;
flex-shrink: 0;
}
.chat-file-delete {
position: relative;
&:hover {
color: red;
}
/* Increase hit area for better clickability */
&:before {
content: '';
position: absolute;
top: -10px;
right: -10px;
bottom: -10px;
left: -10px;
}
}
</style>

View File

@@ -0,0 +1,124 @@
<script lang="ts" setup>
import IconChat from 'virtual:icons/mdi/chat';
import IconChevronDown from 'virtual:icons/mdi/chevron-down';
import { nextTick, ref } from 'vue';
import Chat from '@n8n/chat/components/Chat.vue';
import { chatEventBus } from '@n8n/chat/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 v-show="isOpen" class="chat-window">
<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 '@n8n/chat/components/Button.vue';
import { useI18n } from '@n8n/chat/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 PoweredBy from '@n8n/chat/components/PoweredBy.vue';
import { useI18n } from '@n8n/chat/composables';
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,350 @@
<script setup lang="ts">
import { useFileDialog } from '@vueuse/core';
import IconPaperclip from 'virtual:icons/mdi/paperclip';
import IconSend from 'virtual:icons/mdi/send';
import { computed, onMounted, onUnmounted, ref, unref } from 'vue';
import { useI18n, useChat, useOptions } from '@n8n/chat/composables';
import { chatEventBus } from '@n8n/chat/event-buses';
import ChatFile from './ChatFile.vue';
export interface ChatInputProps {
placeholder?: string;
}
const props = withDefaults(defineProps<ChatInputProps>(), {
placeholder: 'inputPlaceholder',
});
export interface ArrowKeyDownPayload {
key: 'ArrowUp' | 'ArrowDown';
currentInputValue: string;
}
const { t } = useI18n();
const emit = defineEmits<{
arrowKeyDown: [value: ArrowKeyDownPayload];
}>();
const { options } = useOptions();
const chatStore = useChat();
const { waitingForResponse } = chatStore;
const files = ref<FileList | null>(null);
const chatTextArea = ref<HTMLTextAreaElement | null>(null);
const input = ref('');
const isSubmitting = ref(false);
const resizeObserver = ref<ResizeObserver | null>(null);
const isSubmitDisabled = computed(() => {
return input.value === '' || unref(waitingForResponse) || options.disabled?.value === true;
});
const isInputDisabled = computed(() => options.disabled?.value === true);
const isFileUploadDisabled = computed(
() => isFileUploadAllowed.value && unref(waitingForResponse) && !options.disabled?.value,
);
const isFileUploadAllowed = computed(() => unref(options.allowFileUploads) === true);
const allowedFileTypes = computed(() => unref(options.allowedFilesMimeTypes));
const styleVars = computed(() => {
const controlsCount = isFileUploadAllowed.value ? 2 : 1;
return {
'--controls-count': controlsCount,
};
});
const {
open: openFileDialog,
reset: resetFileDialog,
onChange,
} = useFileDialog({
multiple: true,
reset: false,
});
onChange((newFiles) => {
if (!newFiles) return;
const newFilesDT = new DataTransfer();
// Add current files
if (files.value) {
for (let i = 0; i < files.value.length; i++) {
newFilesDT.items.add(files.value[i]);
}
}
for (let i = 0; i < newFiles.length; i++) {
newFilesDT.items.add(newFiles[i]);
}
files.value = newFilesDT.files;
});
onMounted(() => {
chatEventBus.on('focusInput', focusChatInput);
chatEventBus.on('blurInput', blurChatInput);
chatEventBus.on('setInputValue', setInputValue);
if (chatTextArea.value) {
resizeObserver.value = new ResizeObserver((entries) => {
for (const entry of entries) {
if (entry.target === chatTextArea.value) {
adjustHeight({ target: chatTextArea.value } as unknown as Event);
}
}
});
// Start observing the textarea
resizeObserver.value.observe(chatTextArea.value);
}
});
onUnmounted(() => {
chatEventBus.off('focusInput', focusChatInput);
chatEventBus.off('blurInput', blurChatInput);
chatEventBus.off('setInputValue', setInputValue);
if (resizeObserver.value) {
resizeObserver.value.disconnect();
resizeObserver.value = null;
}
});
function blurChatInput() {
if (chatTextArea.value) {
chatTextArea.value.blur();
}
}
function focusChatInput() {
if (chatTextArea.value) {
chatTextArea.value.focus();
}
}
function setInputValue(value: string) {
input.value = value;
focusChatInput();
}
async function onSubmit(event: MouseEvent | KeyboardEvent) {
event.preventDefault();
if (isSubmitDisabled.value) {
return;
}
const messageText = input.value;
input.value = '';
isSubmitting.value = true;
await chatStore.sendMessage(messageText, Array.from(files.value ?? []));
isSubmitting.value = false;
resetFileDialog();
files.value = null;
}
async function onSubmitKeydown(event: KeyboardEvent) {
if (event.shiftKey) {
return;
}
await onSubmit(event);
adjustHeight({ target: chatTextArea.value } as unknown as Event);
}
function onFileRemove(file: File) {
if (!files.value) return;
const dt = new DataTransfer();
for (let i = 0; i < files.value.length; i++) {
const currentFile = files.value[i];
if (file.name !== currentFile.name) dt.items.add(currentFile);
}
resetFileDialog();
files.value = dt.files;
}
function onKeyDown(event: KeyboardEvent) {
if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
event.preventDefault();
emit('arrowKeyDown', {
key: event.key,
currentInputValue: input.value,
});
}
}
function onOpenFileDialog() {
if (isFileUploadDisabled.value) return;
openFileDialog({ accept: unref(allowedFileTypes) });
}
function adjustHeight(event: Event) {
const textarea = event.target as HTMLTextAreaElement;
// Set to content minimum to get the right scrollHeight
textarea.style.height = 'var(--chat--textarea--height)';
// Get the new height, with a small buffer for padding
const newHeight = Math.min(textarea.scrollHeight, 480); // 30rem
textarea.style.height = `${newHeight}px`;
}
</script>
<template>
<div class="chat-input" :style="styleVars" @keydown.stop="onKeyDown">
<div class="chat-inputs">
<div v-if="$slots.leftPanel" class="chat-input-left-panel">
<slot name="leftPanel" />
</div>
<textarea
ref="chatTextArea"
v-model="input"
data-test-id="chat-input"
:disabled="isInputDisabled"
:placeholder="t(props.placeholder)"
@keydown.enter="onSubmitKeydown"
@input="adjustHeight"
@mousedown="adjustHeight"
@focus="adjustHeight"
/>
<div class="chat-inputs-controls">
<button
v-if="isFileUploadAllowed"
:disabled="isFileUploadDisabled"
class="chat-input-file-button"
data-test-id="chat-attach-file-button"
@click="onOpenFileDialog"
>
<IconPaperclip height="24" width="24" />
</button>
<button :disabled="isSubmitDisabled" class="chat-input-send-button" @click="onSubmit">
<IconSend height="24" width="24" />
</button>
</div>
</div>
<div v-if="files?.length && !isSubmitting" class="chat-files">
<ChatFile
v-for="file in files"
:key="file.name"
:file="file"
:is-removable="true"
:is-previewable="true"
@remove="onFileRemove"
/>
</div>
</div>
</template>
<style lang="scss" scoped>
.chat-input {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
flex-direction: column;
position: relative;
* {
box-sizing: border-box;
}
}
.chat-inputs {
width: 100%;
display: flex;
justify-content: center;
align-items: flex-end;
textarea {
font-family: inherit;
font-size: var(--chat--input--font-size, inherit);
width: 100%;
border: var(--chat--input--border, 0);
border-radius: var(--chat--input--border-radius, 0);
padding: var(--chat--input--padding, 0.8rem);
min-height: var(--chat--textarea--height, 2.5rem); // Set a smaller initial height
max-height: var(--chat--textarea--max-height, 30rem);
height: var(--chat--textarea--height, 2.5rem); // Set initial height same as min-height
resize: none;
overflow-y: auto;
background: var(--chat--input--background, white);
color: var(--chat--input--text-color, initial);
outline: none;
line-height: var(--chat--input--line-height, 1.5);
&::placeholder {
font-size: var(--chat--input--placeholder--font-size, var(--chat--input--font-size, inherit));
}
&:focus,
&:hover {
border-color: var(--chat--input--border-active, 0);
}
}
}
.chat-inputs-controls {
display: flex;
}
.chat-input-send-button,
.chat-input-file-button {
height: var(--chat--textarea--height);
width: var(--chat--textarea--height);
background: var(--chat--input--send--button--background, white);
cursor: pointer;
color: var(--chat--input--send--button--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;
svg {
min-width: fit-content;
}
&[disabled] {
cursor: no-drop;
color: var(--chat--color-disabled);
}
.chat-input-send-button {
&:hover,
&:focus {
background: var(
--chat--input--send--button--background-hover,
var(--chat--input--send--button--background)
);
color: var(--chat--input--send--button--color-hover, var(--chat--color-secondary-shade-50));
}
}
}
.chat-input-file-button {
background: var(--chat--input--file--button--background, white);
color: var(--chat--input--file--button--color, var(--chat--color-secondary));
&:hover {
background: var(
--chat--input--file--button--background-hover,
var(--chat--input--file--button--background)
);
color: var(--chat--input--file--button--color-hover, var(--chat--color-secondary-shade-50));
}
}
.chat-files {
display: flex;
overflow-x: hidden;
overflow-y: auto;
width: 100%;
flex-direction: row;
flex-wrap: wrap;
gap: 0.5rem;
padding: var(--chat--files-spacing, 0.25rem);
}
.chat-input-left-panel {
width: var(--chat--input--left--panel--width, 2rem);
margin-left: 0.4rem;
}
</style>

View File

@@ -0,0 +1,100 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue';
import { chatEventBus } from '@n8n/chat/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" ref="chatBodyRef" class="chat-body">
<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 {
display: flex;
flex-direction: column;
justify-content: center;
gap: 1em;
height: var(--chat--header-height, auto);
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));
border-top: var(--chat--header--border-top, none);
border-bottom: var(--chat--header--border-bottom, none);
border-left: var(--chat--header--border-left, none);
border-right: var(--chat--header--border-right, none);
h1 {
font-size: var(--chat--heading--font-size);
color: var(--chat--header--color, var(--chat--color-light));
}
p {
font-size: var(--chat--subtitle--font-size, inherit);
line-height: var(--chat--subtitle--line-height, 1.8);
}
}
.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,227 @@
<script lang="ts" setup>
/* eslint-disable @typescript-eslint/naming-convention */
import hljs from 'highlight.js/lib/core';
import bash from 'highlight.js/lib/languages/bash';
import javascript from 'highlight.js/lib/languages/javascript';
import python from 'highlight.js/lib/languages/python';
import typescript from 'highlight.js/lib/languages/typescript';
import xml from 'highlight.js/lib/languages/xml';
import type MarkdownIt from 'markdown-it';
import markdownLink from 'markdown-it-link-attributes';
import { computed, ref, toRefs, onMounted } from 'vue';
import VueMarkdown from 'vue-markdown-render';
import { useOptions } from '@n8n/chat/composables';
import type { ChatMessage, ChatMessageText } from '@n8n/chat/types';
import ChatFile from './ChatFile.vue';
const props = defineProps<{
message: ChatMessage;
}>();
hljs.registerLanguage('javascript', javascript);
hljs.registerLanguage('typescript', typescript);
hljs.registerLanguage('python', python);
hljs.registerLanguage('xml', xml);
hljs.registerLanguage('bash', bash);
defineSlots<{
beforeMessage(props: { message: ChatMessage }): ChatMessage;
default: { message: ChatMessage };
}>();
const { message } = toRefs(props);
const { options } = useOptions();
const messageContainer = ref<HTMLElement | null>(null);
const fileSources = ref<Record<string, string>>({});
const messageText = computed(() => {
return (message.value as ChatMessageText).text || '&lt;Empty response&gt;';
});
const classes = computed(() => {
return {
'chat-message-from-user': message.value.sender === 'user',
'chat-message-from-bot': message.value.sender === 'bot',
'chat-message-transparent': message.value.transparent === true,
};
});
const linksNewTabPlugin = (vueMarkdownItInstance: MarkdownIt) => {
vueMarkdownItInstance.use(markdownLink, {
attrs: {
target: '_blank',
rel: 'noopener',
},
});
};
const scrollToView = () => {
if (messageContainer.value?.scrollIntoView) {
messageContainer.value.scrollIntoView({
block: 'start',
});
}
};
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
},
};
const messageComponents = { ...(options?.messageComponents ?? {}) };
defineExpose({ scrollToView });
const readFileAsDataURL = async (file: File): Promise<string> =>
await new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsDataURL(file);
});
onMounted(async () => {
if (message.value.files) {
for (const file of message.value.files) {
try {
const dataURL = await readFileAsDataURL(file);
fileSources.value[file.name] = dataURL;
} catch (error) {
console.error('Error reading file:', error);
}
}
}
});
</script>
<template>
<div ref="messageContainer" class="chat-message" :class="classes">
<div v-if="$slots.beforeMessage" class="chat-message-actions">
<slot name="beforeMessage" v-bind="{ message }" />
</div>
<slot>
<template v-if="message.type === 'component' && messageComponents[message.key]">
<component :is="messageComponents[message.key]" v-bind="message.arguments" />
</template>
<VueMarkdown
v-else
class="chat-message-markdown"
:source="messageText"
:options="markdownOptions"
:plugins="[linksNewTabPlugin]"
/>
<div v-if="(message.files ?? []).length > 0" class="chat-message-files">
<div v-for="file in message.files ?? []" :key="file.name" class="chat-message-file">
<ChatFile :file="file" :is-removable="false" :is-previewable="true" />
</div>
</div>
</slot>
</div>
</template>
<style lang="scss">
.chat-message {
display: block;
position: relative;
max-width: fit-content;
font-size: var(--chat--message--font-size, 1rem);
padding: var(--chat--message--padding, var(--chat--spacing));
border-radius: var(--chat--message--border-radius, var(--chat--border-radius));
scroll-margin: 3rem;
.chat-message-actions {
position: absolute;
bottom: calc(100% - 0.5rem);
left: 0;
opacity: 0;
transform: translateY(-0.25rem);
display: flex;
gap: 1rem;
}
&.chat-message-from-user .chat-message-actions {
left: auto;
right: 0;
}
&:hover {
.chat-message-actions {
opacity: 1;
}
}
p {
line-height: var(--chat--message-line-height, 1.5);
word-wrap: break-word;
}
// Default message gap is half of the spacing
+ .chat-message {
margin-top: var(--chat--message--margin-bottom, calc(var(--chat--spacing) * 1));
}
// Spacing between messages from different senders is double the individual message gap
&.chat-message-from-user + &.chat-message-from-bot,
&.chat-message-from-bot + &.chat-message-from-user {
margin-top: var(--chat--spacing);
}
&.chat-message-from-bot {
&:not(.chat-message-transparent) {
background-color: var(--chat--message--bot--background);
border: var(--chat--message--bot--border, none);
}
color: var(--chat--message--bot--color);
border-bottom-left-radius: 0;
}
&.chat-message-from-user {
&:not(.chat-message-transparent) {
background-color: var(--chat--message--user--background);
border: var(--chat--message--user--border, none);
}
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);
}
}
.chat-message-files {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
padding-top: 0.5rem;
}
}
</style>

View File

@@ -0,0 +1,121 @@
<script lang="ts" setup>
import { computed, onMounted, ref } from 'vue';
import type { ChatMessage } from '@n8n/chat/types';
import { Message } from './index';
const props = withDefaults(
defineProps<{
animation?: 'bouncing' | 'scaling';
}>(),
{
animation: 'bouncing',
},
);
const message: ChatMessage = {
id: 'typing',
text: '',
sender: 'bot',
createdAt: '',
};
const messageContainer = ref<InstanceType<typeof Message>>();
const classes = computed(() => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
'chat-message-typing': true,
[`chat-message-typing-animation-${props.animation}`]: true,
};
});
onMounted(() => {
messageContainer.value?.scrollToView();
});
</script>
<template>
<Message
ref="messageContainer"
:class="classes"
:message="message"
data-test-id="chat-message-typing"
>
<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,56 @@
<script lang="ts" setup>
import { ref, watch } from 'vue';
import Message from '@n8n/chat/components/Message.vue';
import MessageTyping from '@n8n/chat/components/MessageTyping.vue';
import { useChat } from '@n8n/chat/composables';
import type { ChatMessage } from '@n8n/chat/types';
defineProps<{
messages: ChatMessage[];
}>();
defineSlots<{
beforeMessage(props: { message: ChatMessage }): ChatMessage;
}>();
const chatStore = useChat();
const messageComponents = ref<Array<InstanceType<typeof Message>>>([]);
const { initialMessages, waitingForResponse } = chatStore;
watch(
() => messageComponents.value.length,
() => {
const lastMessageComponent = messageComponents.value[messageComponents.value.length - 1];
if (lastMessageComponent) {
lastMessageComponent.scrollToView();
}
},
);
</script>
<template>
<div class="chat-messages-list">
<Message
v-for="initialMessage in initialMessages"
:key="initialMessage.id"
:message="initialMessage"
/>
<template v-for="message in messages" :key="message.id">
<Message ref="messageComponents" :message="message">
<template #beforeMessage="{ message }">
<slot name="beforeMessage" v-bind="{ message }" />
</template>
</Message>
</template>
<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,8 @@
import { inject } from 'vue';
import { ChatSymbol } from '@n8n/chat/constants';
import type { Chat } from '@n8n/chat/types';
export function useChat() {
return inject(ChatSymbol) as Chat;
}

View File

@@ -0,0 +1,22 @@
import { isRef } from 'vue';
import { useOptions } from '@n8n/chat/composables/useOptions';
export function useI18n() {
const { options } = useOptions();
const language = options?.defaultLanguage ?? 'en';
function t(key: string): string {
const val = options?.i18n?.[language]?.[key];
if (isRef(val)) {
return val.value as string;
}
return val ?? key;
}
function te(key: string): boolean {
return !!options?.i18n?.[language]?.[key];
}
return { t, te };
}

View File

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

View File

@@ -0,0 +1,30 @@
import type { ChatOptions } from '@n8n/chat/types';
export const defaultOptions: ChatOptions = {
webhookUrl: 'http://localhost:5678',
webhookConfig: {
method: 'POST',
headers: {},
},
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: {
title: 'Hi there! 👋',
subtitle: "Start a chat. We're here to help you 24/7.",
footer: '',
getStarted: 'New Conversation',
inputPlaceholder: 'Type your question..',
closeButtonTooltip: 'Close chat',
},
},
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,9 @@
import type { InjectionKey } from 'vue';
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>;
// eslint-disable-next-line @typescript-eslint/naming-convention
export const ChatOptionsSymbol = 'ChatOptions' as unknown as InjectionKey<ChatOptions>;

View File

@@ -0,0 +1,38 @@
: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;
--chat--heading--font-size: 2em;
}

View File

@@ -0,0 +1,2 @@
@import 'tokens';
@import 'markdown';

View File

@@ -0,0 +1,654 @@
@use 'sass:meta';
@include meta.load-css('highlight.js/styles/github.css');
@mixin hljs-dark-theme {
@include meta.load-css('highlight.js/styles/github-dark-dimmed.css');
}
body {
&[data-theme='dark'] {
@include hljs-dark-theme;
}
@media (prefers-color-scheme: dark) {
@include hljs-dark-theme;
}
}
// https://github.com/pxlrbt/markdown-css
.chat-message-markdown {
/*
universalize.css (v1.0.2) — by Alexander Sandberg (https://alexandersandberg.com)
------------------------------------------------------------------------------
Based on Sanitize.css (https://github.com/csstools/sanitize.css).
(all) = Used for all browsers.
x lines = Applies to x lines down, including current line.
------------------------------------------------------------------------------
*/
/*
1. Use default UI font (all)
2. Make font size more accessible to everyone (all)
3. Make line height consistent (all)
4. Prevent font size adjustment after orientation changes (IE, iOS)
5. Prevent overflow from long words (all)
*/
line-height: 1.4; /* 3 */
-webkit-text-size-adjust: 100%; /* 4 */
word-break: break-word; /* 5 */
/*
Prevent padding and border from affecting width (all)
*/
*,
::before,
::after {
box-sizing: border-box;
}
/*
1. Inherit text decoration (all)
2. Inherit vertical alignment (all)
*/
::before,
::after {
text-decoration: inherit; /* 1 */
vertical-align: inherit; /* 2 */
}
/*
Remove inconsistent and unnecessary margins
*/
body, /* (all) */
dl dl, /* (Chrome, Edge, IE, Safari) 5 lines */
dl ol,
dl ul,
ol dl,
ul dl,
ol ol, /* (Edge 18-, IE) 4 lines */
ol ul,
ul ol,
ul ul,
button, /* (Safari) 3 lines */
input,
select,
textarea {
/* (Firefox, Safari) */
margin: 0;
}
/*
1. Show overflow (IE18-, IE)
2. Correct sizing (Firefox)
*/
hr {
overflow: visible;
height: 0;
}
/*
Add correct display
*/
main, /* (IE11) */
details {
/* (Edge 18-, IE) */
display: block;
}
summary {
/* (all) */
display: list-item;
}
/*
Remove style on navigation lists (all)
*/
nav ol,
nav ul {
list-style: none;
padding: 0;
}
/*
1. Use default monospace UI font (all)
2. Correct font sizing (all)
*/
pre,
code,
kbd,
samp {
font-family:
/* macOS 10.10+ */
'Menlo',
/* Windows 6+ */ 'Consolas',
/* Android 4+ */ 'Roboto Mono',
/* Ubuntu 10.10+ */ 'Ubuntu Monospace',
/* KDE Plasma 5+ */ 'Noto Mono',
/* KDE Plasma 4+ */ 'Oxygen Mono',
/* Linux/OpenOffice fallback */ 'Liberation Mono',
/* fallback */ monospace,
/* macOS emoji */ 'Apple Color Emoji',
/* Windows emoji */ 'Segoe UI Emoji',
/* Windows emoji */ 'Segoe UI Symbol',
/* Linux emoji */ 'Noto Color Emoji'; /* 1 */
font-size: 1em; /* 2 */
}
/*
1. Change cursor for <abbr> elements (all)
2. Add correct text decoration (Edge 18-, IE, Safari)
*/
abbr[title] {
cursor: help; /* 1 */
text-decoration: underline; /* 2 */
-webkit-text-decoration: underline dotted;
text-decoration: underline dotted; /* 2 */
}
/*
Add correct font weight (Chrome, Edge, Safari)
*/
b,
strong {
font-weight: bolder;
}
/*
Add correct font size (all)
*/
small {
font-size: 80%;
}
/*
Change alignment on media elements (all)
*/
audio,
canvas,
iframe,
img,
svg,
video {
vertical-align: middle;
}
/*
Remove border on iframes (all)
*/
iframe {
border-style: none;
}
/*
Change fill color to match text (all)
*/
svg:not([fill]) {
fill: currentColor;
}
/*
Hide overflow (IE11)
*/
svg:not(:root) {
overflow: hidden;
}
/*
Show overflow (Edge 18-, IE)
*/
button,
input {
overflow: visible;
}
/*
Remove inheritance of text transform (Edge 18-, Firefox, IE)
*/
button,
select {
text-transform: none;
}
/*
Correct inability to style buttons (iOS, Safari)
*/
button,
[type='button'],
[type='reset'],
[type='submit'] {
-webkit-appearance: button;
}
/*
1. Fix inconsistent appearance (all)
2. Correct padding (Firefox)
*/
fieldset {
border: 1px solid #666; /* 1 */
padding: 0.35em 0.75em 0.625em; /* 2 */
}
/*
1. Correct color inheritance from <fieldset> (IE)
2. Correct text wrapping (Edge 18-, IE)
*/
legend {
color: inherit; /* 1 */
display: table; /* 2 */
max-width: 100%; /* 2 */
white-space: normal; /* 2 */
}
/*
1. Add correct display (Edge 18-, IE)
2. Add correct vertical alignment (Chrome, Edge, Firefox)
*/
progress {
display: inline-block; /* 1 */
vertical-align: baseline; /* 2 */
}
/*
1. Remove default vertical scrollbar (IE)
2. Change resize direction (all)
*/
textarea {
overflow: auto; /* 1 */
resize: vertical; /* 2 */
}
/*
1. Correct outline style (Safari)
2. Correct odd appearance (Chrome, Edge, Safari)
*/
[type='search'] {
outline-offset: -2px; /* 1 */
-webkit-appearance: textfield; /* 2 */
}
/*
Correct cursor style of increment and decrement buttons (Safari)
*/
::-webkit-inner-spin-button,
::-webkit-outer-spin-button {
height: auto;
}
/*
Correct text style (Chrome, Edge, Safari)
*/
::-webkit-input-placeholder {
color: inherit;
opacity: 0.54;
}
/*
Remove inner padding (Chrome, Edge, Safari on macOS)
*/
::-webkit-search-decoration {
-webkit-appearance: none;
}
/*
1. Inherit font properties (Safari)
2. Correct inability to style upload buttons (iOS, Safari)
*/
::-webkit-file-upload-button {
font: inherit; /* 1 */
-webkit-appearance: button; /* 2 */
}
/*
Remove inner border and padding of focus outlines (Firefox)
*/
::-moz-focus-inner {
border-style: none;
padding: 0;
}
/*
Restore focus outline style (Firefox)
*/
:-moz-focusring {
outline: 1px dotted ButtonText;
}
/*
Remove :invalid styles (Firefox)
*/
:-moz-ui-invalid {
box-shadow: none;
}
/*
Change cursor on busy elements (all)
*/
[aria-busy='true'] {
cursor: progress;
}
/*
Change cursor on control elements (all)
*/
[aria-controls] {
cursor: pointer;
}
/*
Change cursor on disabled, non-editable, or inoperable elements (all)
*/
[aria-disabled='true'],
[disabled] {
cursor: not-allowed;
}
/*
Change display on visually hidden accessible elements (all)
*/
[aria-hidden='false'][hidden] {
display: inline;
display: initial;
}
[aria-hidden='false'][hidden]:not(:focus) {
clip: rect(0, 0, 0, 0);
position: absolute;
}
/*
Print out URLs after links (all)
*/
@media print {
a[href^='http']::after {
content: ' (' attr(href) ')';
}
}
/* ----- Variables ----- */
/* Light mode default, dark mode if recognized as preferred */
:root {
--background-main: #fefefe;
--background-element: #eee;
--background-inverted: #282a36;
--text-main: #1f1f1f;
--text-alt: #333;
--text-inverted: #fefefe;
--border-element: #282a36;
--theme: #7a283a;
--theme-light: hsl(0, 25%, 65%);
--theme-dark: hsl(0, 25%, 45%);
}
/* @media (prefers-color-scheme: dark) {
:root {
--background-main: #282a36;
--text-main: #fefefe;
}
} */
/* ----- Base ----- */
body {
margin: auto;
max-width: 36rem;
min-height: 100%;
overflow-x: hidden;
}
/* ----- Typography ----- */
h1,
h2,
h3,
h4,
h5,
h6 {
margin: 2rem 0 0.8em;
}
/*
Heading sizes based on a modular scale of 1.25 (all)
*/
h1 {
font-size: 2.441rem;
line-height: 1.1;
}
h2 {
font-size: 1.953rem;
line-height: 1.15;
}
h3 {
font-size: 1.563rem;
line-height: 1.2;
}
h4 {
font-size: 1.25rem;
line-height: 1.3;
}
h5 {
font-size: 1rem;
line-height: 1.4;
}
h6 {
font-size: 1rem;
line-height: 1.4;
/* differentiate from h5, somehow. color or style? */
}
p,
ul,
ol,
figure {
margin: 0.6rem 0 1.2rem;
}
/*
Subtitles
- Change to header h* + span instead?
- Add support for taglines (small title above main) as well? Needs <header>:
header > span:first-child
*/
h1 span,
h2 span,
h3 span,
h4 span,
h5 span,
h6 span {
display: block;
font-size: 1em;
font-style: italic;
font-weight: normal;
line-height: 1.3;
margin-top: 0.3em;
}
h1 span {
font-size: 0.6em;
}
h2 span {
font-size: 0.7em;
}
h3 span {
font-size: 0.8em;
}
h4 span {
font-size: 0.9em;
}
small {
font-size: 1em;
opacity: 0.8; /* or some other way of differentiating it from body text */
}
mark {
background: pink; /* change to proper color, based on theme */
}
/*
Define a custom tab-size in browsers that support it.
*/
pre {
-moz-tab-size: 4;
-o-tab-size: 4;
tab-size: 4;
}
/*
Long underlined text can be hard to read for dyslexics. Replace with bold.
*/
ins {
text-decoration: none;
font-weight: bolder;
}
blockquote {
border-left: 0.3rem solid #7a283a;
border-left: 0.3rem solid var(--theme);
margin: 0.6rem 0 1.2rem 0;
padding-left: 2rem;
}
blockquote p {
font-size: 1.2em;
font-style: italic;
}
figure {
margin: 0;
}
/* ----- Layout ----- */
body {
background: #fefefe;
background: var(--background-main);
color: #1f1f1f;
color: var(--text-main);
}
a {
color: #7a283a;
color: var(--theme);
text-decoration: underline;
}
a:hover {
color: hsl(0, 25%, 65%);
color: var(--theme-light);
}
a:active {
color: hsl(0, 25%, 45%);
color: var(--theme-dark);
}
:focus {
outline: 3px solid hsl(0, 25%, 65%);
outline: 3px solid var(--theme-light);
outline-offset: 3px;
}
input {
background: #eee;
background: var(--background-element);
padding: 0.5rem 0.65rem;
border-radius: 0.5rem;
border: 2px solid #282a36;
border: 2px solid var(--border-element);
font-size: 1rem;
}
mark {
background: pink; /* change to proper color, based on theme */
padding: 0.1em 0.15em;
}
kbd, /* different style for kbd? */
code {
padding: 0.1em 0.25em;
border-radius: 0.2rem;
-webkit-box-decoration-break: clone;
box-decoration-break: clone;
}
kbd > kbd {
padding-left: 0;
padding-right: 0;
}
pre {
-moz-tab-size: 4;
-o-tab-size: 4;
tab-size: 4;
}
pre code {
display: block;
padding: 0 0 0.5rem 0.5rem;
word-break: normal;
overflow-x: auto;
}
/* ----- Forms ----- */
/* ----- Misc ----- */
[tabindex='-1']:focus {
outline: none;
}
[hidden] {
display: none;
}
[aria-disabled],
[disabled] {
cursor: not-allowed !important;
pointer-events: none !important;
}
/*
Style anchor links only
*/
a[href^='#']::after {
content: '';
}
/*
Skip link
*/
body > a:first-child {
background: #7a283a;
background: var(--theme);
border-radius: 0.2rem;
color: #fefefe;
color: var(--text-inverted);
padding: 0.3em 0.5em;
position: absolute;
top: -10rem;
}
body > a:first-child:focus {
top: 1rem;
}
// Lists
ul,
ol {
padding-left: 1.5rem;
margin-bottom: 1rem;
li {
margin-bottom: 0.5rem;
}
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,44 @@
import './main.scss';
import { createApp } from 'vue';
import { defaultMountingTarget, defaultOptions } from '@n8n/chat/constants';
import { ChatPlugin } from '@n8n/chat/plugins';
import type { ChatOptions } from '@n8n/chat/types';
import { createDefaultMountingTarget } from '@n8n/chat/utils';
import App from './App.vue';
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,5 @@
.n8n-chat {
@import 'highlight.js/styles/github';
}
@import 'css';

View File

@@ -0,0 +1,118 @@
import { v4 as uuidv4 } from 'uuid';
import type { Plugin } from 'vue';
import { computed, nextTick, ref } from 'vue';
import * as api from '@n8n/chat/api';
import { ChatOptionsSymbol, ChatSymbol, localStorageSessionIdKey } from '@n8n/chat/constants';
import { chatEventBus } from '@n8n/chat/event-buses';
import type { ChatMessage, ChatOptions } from '@n8n/chat/types';
// 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, files: File[] = []) {
const sentMessage: ChatMessage = {
id: uuidv4(),
text,
sender: 'user',
files,
createdAt: new Date().toISOString(),
};
messages.value.push(sentMessage);
waitingForResponse.value = true;
void nextTick(() => {
chatEventBus.emit('scrollToBottom');
});
const sendMessageResponse = await api.sendMessage(
text,
files,
currentSessionId.value as string,
options,
);
let textMessage = sendMessageResponse.output ?? sendMessageResponse.text ?? '';
if (textMessage === '' && Object.keys(sendMessageResponse).length > 0) {
try {
textMessage = JSON.stringify(sendMessageResponse, null, 2);
} catch (e) {
// Failed to stringify the object so fallback to empty string
}
}
const receivedMessage: ChatMessage = {
id: uuidv4(),
text: textMessage,
sender: 'bot',
createdAt: new Date().toISOString(),
};
messages.value.push(receivedMessage);
waitingForResponse.value = false;
void nextTick(() => {
chatEventBus.emit('scrollToBottom');
});
}
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();
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';

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,5 @@
.n8n-chat {
@import 'highlight.js/styles/github';
}
@import 'css';

View File

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

View File

@@ -0,0 +1,5 @@
declare module 'virtual:icons/*' {
import { FunctionalComponent, SVGAttributes } from 'vue';
const component: FunctionalComponent<SVGAttributes>;
export default component;
}

View File

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

View File

@@ -0,0 +1,20 @@
export type ChatMessage<T = Record<string, unknown>> = ChatMessageComponent<T> | ChatMessageText;
export interface ChatMessageComponent<T = Record<string, unknown>> extends ChatMessageBase {
type: 'component';
key: string;
arguments: T;
}
export interface ChatMessageText extends ChatMessageBase {
type?: 'text';
text: string;
}
interface ChatMessageBase {
id: string;
createdAt: string;
transparent?: boolean;
sender: 'user' | 'bot';
files?: File[];
}

View File

@@ -0,0 +1,36 @@
import type { Component, Ref } from 'vue';
export interface ChatOptions {
webhookUrl: string;
webhookConfig?: {
method?: 'GET' | 'POST';
headers?: Record<string, string>;
};
target?: string | Element;
mode?: 'window' | 'fullscreen';
showWindowCloseButton?: boolean;
showWelcomeScreen?: boolean;
loadPreviousSession?: boolean;
chatInputKey?: string;
chatSessionKey?: string;
defaultLanguage?: 'en';
initialMessages?: string[];
metadata?: Record<string, unknown>;
i18n: Record<
string,
{
title: string;
subtitle: string;
footer: string;
getStarted: string;
inputPlaceholder: string;
closeButtonTooltip: string;
[message: string]: string;
}
>;
theme?: {};
messageComponents?: Record<string, Component>;
disabled?: Ref<boolean>;
allowFileUploads?: Ref<boolean> | boolean;
allowedFilesMimeTypes?: Ref<string> | string;
}

View File

@@ -0,0 +1,18 @@
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;
text?: 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,23 @@
{
"extends": "../../../../tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"baseUrl": "src",
"target": "esnext",
"module": "esnext",
"allowJs": true,
"importHelpers": true,
"incremental": false,
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true,
"types": ["vitest/globals"],
"paths": {
"@n8n/chat/*": ["./*"]
},
"lib": ["esnext", "dom", "dom.iterable", "scripthost"],
// TODO: remove all options below this line
"useUnknownInCatchVariables": false
},
"include": ["**/*.ts", "**/*.vue"]
}

View File

@@ -0,0 +1,53 @@
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';
const srcPath = resolve(__dirname, 'src');
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
vue(),
icons({
compiler: 'vue3',
autoInstall: true,
}),
dts(),
],
resolve: {
alias: {
'@': srcPath,
'@n8n/chat': srcPath,
lodash: 'lodash-es',
},
},
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,30 @@
import { resolve } from 'path';
import { mergeConfig } from 'vite';
import { type UserConfig } from 'vitest';
import { defineConfig } from 'vitest/config';
import viteConfig from './vite.config.mts';
const srcPath = resolve(__dirname, 'src');
const vitestConfig = defineConfig({
test: {
globals: true,
environment: 'jsdom',
root: srcPath,
setupFiles: ['./src/__tests__/setup.ts'],
...(process.env.COVERAGE_ENABLED === 'true'
? {
coverage: {
enabled: true,
provider: 'v8',
reporter: process.env.CI === 'true' ? 'cobertura' : 'text-summary',
all: true,
},
}
: {}),
},
}) as UserConfig;
export default mergeConfig(
viteConfig,
vitestConfig,
);